v0.2: covered codes, service marks, and an audit log

Codes are covered until revealed. A reveal is a server-side, per-session
window (60 s, two periods): POST /api/accounts/{id}/reveal opens it and
returns the code, GET /api/codes only ever returns codes for accounts in
that window, and /hide closes it early. The browser therefore never holds
a code it has not asked for by name, which is what makes the log honest.

Settings -> Log is an audit trail in the style of Daito: one line per
event with who, how long ago and from which address. It records what the
server observed - sign-ins and failed attempts (under the name tried),
reveals, accounts added / renamed / removed, delivery and credential
changes, test messages. Entries are JSON lines in audit.jsonl (owner-only,
newest 5,000 kept, compacted at twice that). The address comes from
X-Forwarded-For / X-Real-IP when parseable, else the TCP peer via
ConnectInfo; in Docker bridge networking that peer is the gateway, which
the README and template now say.

The account list shows each service's mark instead of an initial. The
pack (crates/webui/src/icons.js, generated by scripts/fetch-icons.py) is
~175 marks from Simple Icons (CC0) plus Font Awesome Free brands (CC BY
4.0) for Amazon, AWS, Microsoft, LinkedIn, Slack, Xbox, Salesforce and
Yahoo, which Simple Icons no longer carries. Matching is by issuer name,
then shorter word prefixes, then the label's mail domain; unmatched
accounts keep the initial tile. Marks that would vanish against a theme's
tile fall back to ink for that theme only.

The setup and Delivery platform pickers are plain text; the coloured dots
are gone.

Router tests cover the reveal window, session scoping, 401s, and the
forwarded address landing in the log.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Miles WardandClaude Fable 5.1 committed 2026-09-09 02:08:47 -04:00
1 parent 3c83cd9928
commit 04a86ead95
20 files changed
+1802 -118

No files matched your search

Generated
+8 -6
View File
@@ -1210,7 +1210,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openmfa"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"anyhow",
"axum",
@@ -1225,7 +1225,7 @@ dependencies = [
[[package]]
name = "openmfa-core"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"argon2",
"base32",
@@ -1250,7 +1250,7 @@ dependencies = [
[[package]]
name = "openmfa-delivery"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"axum",
"openmfa-core",
@@ -1268,7 +1268,7 @@ dependencies = [
[[package]]
name = "openmfa-http-api"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -1282,7 +1282,9 @@ dependencies = [
"rqrr",
"serde",
"serde_json",
"tempfile",
"tokio",
"tower",
"tower-http",
"tracing",
"uuid",
@@ -1290,7 +1292,7 @@ dependencies = [
[[package]]
name = "openmfa-render"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"image",
"resvg",
@@ -1302,7 +1304,7 @@ dependencies = [
[[package]]
name = "openmfa-webui"
version = "0.1.1"
version = "0.2.0"
[[package]]
name = "parking_lot"
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
version = "0.1.1"
version = "0.2.0"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
+54 -7
View File
@@ -11,7 +11,8 @@
</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.
searchable list of accounts behind recognisable service marks, each code
covered until you reveal it, with a countdown ring while it shows.
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.
@@ -165,6 +166,9 @@ What the app does hold up its end on:
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.
- Codes are **covered until revealed**, per session, and every reveal is
logged with the caller's address — a screen left unattended shows dots, and
a borrowed session leaves a trace.
- 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.
@@ -216,6 +220,23 @@ of Discord, Slack or Telegram — optional, so you can wire a platform later fro
Settings → Delivery. Switching platforms afterwards keeps the other platforms'
details, so you can move back and forth without re-pasting tokens.
**Reading a code.** Codes are covered by default — six dots and an eye. Click
to reveal one: the digits appear with their countdown ring and a copy button,
stay for a minute (long enough to survive a rollover), then go back under
cover on their own. A reveal is a request to the server, which is what makes
the log below trustworthy: the browser never holds a code it hasn't asked for
by name, so "covered" is not a CSS trick.
**Service marks.** An `otpauth://` link carries no artwork — only the issuer,
the label and the secret — so OpenMFA ships a pack of about 175 marks
([Simple Icons](https://simpleicons.org/), plus a few Font Awesome brands for
the ones Simple Icons no longer carries: Amazon, AWS, Microsoft, LinkedIn,
Slack, Xbox, Salesforce, Yahoo) and matches on the issuer name — the whole
name first, then shorter prefixes ("Google Workspace" → Google), then the
label's mail domain (`[email protected]` → Gmail). Unmatched accounts keep the
initial tile. `scripts/fetch-icons.py` regenerates the pack; add a slug or an
alias there.
**Adding an account** offers three routes:
- **Upload** a QR code image — PNG, JPEG, WebP, GIF, BMP or TIFF. Screenshots,
@@ -263,6 +284,22 @@ 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.
### The log
**Settings → Log** is an audit trail in the style of Daito: one line per event
— *admin revealed GitHub · [email protected]* — with how long ago, who, and
from which address underneath. It records what the server did, never what the
browser claims: sign-ins (and failed attempts, under the name that was tried),
every reveal, accounts added, renamed and removed, delivery and credential
changes, test messages. Entries live in `audit.jsonl` beside the vault,
owner-only; the newest 5,000 are kept.
The address is the TCP peer unless a reverse proxy forwards the real one in
`X-Forwarded-For` or `X-Real-IP`. On Unraid in **bridge** networking the peer
is Docker's gateway (`172.17.0.1`) for every LAN client — put the app behind
Nginx Proxy Manager, SWAG or a Cloudflare tunnel, or run it on `host` or a
custom `br0` network, to see real addresses.
---
## Configuration
@@ -294,11 +331,11 @@ but Text is the frugal choice for many accounts.
```
crates/
core/ TOTP, vault, crypto, settings (incl. provider config), admin auth,
Google Authenticator export parsing — no network
the audit log, 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
webui/ Embedded HTML/CSS/JS and the service-icon pack, compiled into the binary
http-api/ axum router, sessions and reveal windows, QR decoding, the log API
openmfa/ The binary
```
@@ -316,7 +353,7 @@ The UI follows [Beautiful UI](https://www.beautifului.dev/) — OKLCH colour
ramps, hairline-ring shadows, inset fields against raised buttons, Inter and
JetBrains Mono. The accent is monochrome in the vein of [Daito](https://daito.io):
ink in light mode, near-white in dark, so the only colour on screen is status
(live, expiring, error) and the three platform dots. Light and dark both ship.
(live, expiring, error) and the service marks. Light and dark both ship.
### Testing
@@ -327,7 +364,9 @@ 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.
renames refresh without waiting for a rollover. The router is exercised end to
end as well: codes stay covered until revealed, a reveal is scoped to the
session that asked, and the log carries the forwarded client address.
---
@@ -345,6 +384,11 @@ renames refresh without waiting for a rollover.
## Changelog
- **v0.2** — Covered codes, service marks, and a log. Codes show as dots until
revealed (server-side, per session, for a minute); the account list shows
each service's mark instead of an initial, from a bundled pack of ~175;
**Settings → Log** records reveals, sign-ins and every change with who and
from which address; the setup and delivery pickers are plain text.
- **v0.1.1** — Monochrome UI. The accent is ink rather than blue across the
primary buttons, the floating **Add an account** button, the empty-state
shield, the settings tabs and the focus glow on inputs; the settings tabs
@@ -356,4 +400,7 @@ renames refresh without waiting for a rollover.
## Licence
MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License
1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`).
1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`). The service-icon pack in
`crates/webui/src/icons.js` is generated from [Simple Icons](https://simpleicons.org/)
(CC0 1.0) and [Font Awesome Free](https://fontawesome.com/license/free) brand
icons (CC BY 4.0); the marks themselves remain trademarks of their owners.
+427
View File
@@ -0,0 +1,427 @@
//! Append-only audit log: who did what, from where, when.
//!
//! Every entry is one JSON line in `audit.jsonl` under the work directory,
//! written as it happens. The most recent [`KEEP`] entries are also held in
//! memory, which is what the UI reads; the file is compacted back down to
//! that many once it has grown to twice the size, so it can't fill a disk.
//!
//! The log records what the *server* observed — a code was handed out, a
//! session was opened, an account was renamed. Nothing is client-reported,
//! so an entry means the thing actually happened.
use crate::unix_now_millis;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub const FILE_NAME: &str = "audit.jsonl";
/// Entries held in memory and served to the UI. The file is compacted to
/// this many once it reaches [`COMPACT_AT`] lines.
pub const KEEP: usize = 5000;
const COMPACT_AT: usize = KEEP * 2;
/// Longest actor / target / detail string that is stored. Anything longer is
/// cut, so a hostile client can't bloat the log through a 4 KB username.
const MAX_FIELD: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
/// The admin account was created (first run).
Setup,
Login,
LoginFailed,
Logout,
/// A code was uncovered in the web UI.
Reveal,
AccountAdded,
AccountUpdated,
AccountDeleted,
SettingsUpdated,
TestMessage,
CredentialsUpdated,
}
impl AuditAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Setup => "setup",
Self::Login => "login",
Self::LoginFailed => "login_failed",
Self::Logout => "logout",
Self::Reveal => "reveal",
Self::AccountAdded => "account_added",
Self::AccountUpdated => "account_updated",
Self::AccountDeleted => "account_deleted",
Self::SettingsUpdated => "settings_updated",
Self::TestMessage => "test_message",
Self::CredentialsUpdated => "credentials_updated",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEntry {
/// Monotonic within one log; the cursor for paging.
pub id: u64,
/// Milliseconds since the Unix epoch.
pub ts: u64,
/// Username of whoever did it. For a failed sign-in, the name tried.
pub actor: String,
pub action: AuditAction,
/// What it was done to — an account's issuer, usually.
#[serde(default)]
pub target: String,
/// Free text: the account label, what changed, and so on.
#[serde(default)]
pub detail: String,
/// Client address as seen by the server, or as forwarded by a proxy.
#[serde(default)]
pub ip: String,
}
struct Inner {
entries: VecDeque<AuditEntry>,
next_id: u64,
/// Lines currently in the file, so compaction can be decided without
/// re-reading it.
on_disk: usize,
}
#[derive(Clone)]
pub struct AuditLog {
path: PathBuf,
inner: Arc<Mutex<Inner>>,
}
impl std::fmt::Debug for AuditLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditLog")
.field("path", &self.path)
.field("entries", &self.len())
.finish_non_exhaustive()
}
}
impl AuditLog {
/// Open (or start) the log under `work_dir`.
///
/// A damaged line is skipped with a warning rather than taking the whole
/// log with it: the audit trail is the one file whose partial loss is
/// preferable to a refusal to start.
pub fn open(work_dir: &Path) -> Self {
let path = work_dir.join(FILE_NAME);
let mut entries: VecDeque<AuditEntry> = VecDeque::new();
let mut on_disk = 0usize;
let mut bad = 0usize;
if let Ok(f) = fs::File::open(&path) {
for line in BufReader::new(f).lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
on_disk += 1;
match serde_json::from_str::<AuditEntry>(&line) {
Ok(e) => {
entries.push_back(e);
if entries.len() > KEEP {
entries.pop_front();
}
}
Err(_) => bad += 1,
}
}
}
if bad > 0 {
tracing::warn!(target: "openmfa::audit", skipped = bad, "unreadable audit lines skipped");
}
let next_id = entries.back().map_or(1, |e| e.id + 1);
Self {
path,
inner: Arc::new(Mutex::new(Inner {
entries,
next_id,
on_disk,
})),
}
}
/// Record an event. Never fails: a log that can't be written is reported
/// to the process log, and the request that caused it still succeeds.
pub fn record(
&self,
actor: &str,
action: AuditAction,
target: impl Into<String>,
detail: impl Into<String>,
ip: &str,
) -> AuditEntry {
let mut g = self.inner.lock();
let entry = AuditEntry {
id: g.next_id,
ts: unix_now_millis(),
actor: clip(actor),
action,
target: clip(&target.into()),
detail: clip(&detail.into()),
ip: clip(ip),
};
g.next_id += 1;
g.entries.push_back(entry.clone());
if g.entries.len() > KEEP {
g.entries.pop_front();
}
self.append(&mut g, &entry);
entry
}
/// Newest first. `before` is an entry id: only entries older than it are
/// returned, which is how the UI pages backwards. The flag says whether
/// anything older still exists.
pub fn recent(&self, limit: usize, before: Option<u64>) -> (Vec<AuditEntry>, bool) {
let g = self.inner.lock();
let mut out: Vec<AuditEntry> = g
.entries
.iter()
.rev()
.filter(|e| before.is_none_or(|b| e.id < b))
.take(limit + 1)
.cloned()
.collect();
let more = out.len() > limit;
out.truncate(limit);
(out, more)
}
pub fn len(&self) -> usize {
self.inner.lock().entries.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn append(&self, g: &mut Inner, entry: &AuditEntry) {
if g.on_disk >= COMPACT_AT {
self.rewrite(g);
return;
}
let line = match serde_json::to_string(entry) {
Ok(l) => l,
Err(e) => {
tracing::error!(target: "openmfa::audit", "could not serialize audit entry: {e}");
return;
}
};
match open_append(&self.path).and_then(|mut f| writeln!(f, "{line}")) {
Ok(()) => g.on_disk += 1,
Err(e) => {
tracing::error!(target: "openmfa::audit", "could not write the audit log: {e}");
}
}
}
/// Replace the file with the in-memory window, atomically.
fn rewrite(&self, g: &mut Inner) {
let mut body = String::new();
for e in &g.entries {
if let Ok(l) = serde_json::to_string(e) {
body.push_str(&l);
body.push('\n');
}
}
let tmp = self.path.with_extension("jsonl.tmp");
let result = open_truncate(&tmp)
.and_then(|mut f| f.write_all(body.as_bytes()))
.and_then(|()| fs::rename(&tmp, &self.path));
match result {
Ok(()) => g.on_disk = g.entries.len(),
Err(e) => {
tracing::error!(target: "openmfa::audit", "could not compact the audit log: {e}");
}
}
}
}
fn clip(s: &str) -> String {
// Control characters would let one entry masquerade as several when the
// file is read by eye; keep the text but drop those.
let cleaned: String = s.chars().filter(|c| !c.is_control()).collect();
if cleaned.chars().count() <= MAX_FIELD {
cleaned
} else {
cleaned.chars().take(MAX_FIELD).collect::<String>() + "…"
}
}
/// Owner-only, like the vault: the log names accounts and addresses.
fn private_options() -> OpenOptions {
let mut opts = OpenOptions::new();
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts
}
fn open_append(path: &Path) -> std::io::Result<fs::File> {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
private_options().create(true).append(true).open(path)
}
fn open_truncate(path: &Path) -> std::io::Result<fs::File> {
private_options()
.create(true)
.write(true)
.truncate(true)
.open(path)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn fill(log: &AuditLog, n: usize) {
for i in 0..n {
log.record(
"admin",
AuditAction::Reveal,
format!("Acct {i}"),
"",
"10.0.0.1",
);
}
}
#[test]
fn records_newest_first_and_pages_backwards() {
let dir = tempdir().unwrap();
let log = AuditLog::open(dir.path());
fill(&log, 5);
let (page, more) = log.recent(2, None);
assert_eq!(page.len(), 2);
assert!(more);
assert_eq!(page[0].target, "Acct 4");
assert_eq!(page[1].target, "Acct 3");
let (page2, more2) = log.recent(10, Some(page[1].id));
assert_eq!(
page2.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
["Acct 2", "Acct 1", "Acct 0"]
);
assert!(!more2);
}
#[test]
fn survives_a_reopen_and_keeps_ids_monotonic() {
let dir = tempdir().unwrap();
{
let log = AuditLog::open(dir.path());
log.record("admin", AuditAction::Login, "", "", "192.168.1.5");
log.record(
"admin",
AuditAction::Reveal,
"GitHub",
"[email protected]",
"192.168.1.5",
);
}
let log = AuditLog::open(dir.path());
assert_eq!(log.len(), 2);
let e = log.record("admin", AuditAction::Logout, "", "", "192.168.1.5");
assert_eq!(e.id, 3, "ids continue across restarts");
let (page, _) = log.recent(1, None);
assert_eq!(page[0].action, AuditAction::Logout);
assert_eq!(page[0].ip, "192.168.1.5");
}
#[test]
fn skips_a_damaged_line_instead_of_failing() {
let dir = tempdir().unwrap();
let path = dir.path().join(FILE_NAME);
let good = serde_json::to_string(&AuditEntry {
id: 1,
ts: 1,
actor: "admin".into(),
action: AuditAction::Setup,
target: String::new(),
detail: String::new(),
ip: "127.0.0.1".into(),
})
.unwrap();
fs::write(&path, format!("{good}\nthis is not json\n\n")).unwrap();
let log = AuditLog::open(dir.path());
assert_eq!(log.len(), 1);
assert_eq!(log.record("a", AuditAction::Login, "", "", "x").id, 2);
}
#[test]
fn compacts_the_file_once_it_doubles() {
let dir = tempdir().unwrap();
let log = AuditLog::open(dir.path());
fill(&log, COMPACT_AT + 5);
let lines = fs::read_to_string(dir.path().join(FILE_NAME))
.unwrap()
.lines()
.count();
assert!(
lines <= KEEP + 5,
"file should have been compacted, has {lines} lines"
);
assert_eq!(log.len(), KEEP);
// What is on disk is exactly the in-memory window, newest last.
let reopened = AuditLog::open(dir.path());
assert_eq!(
reopened.len(),
KEEP,
"a reopen keeps the newest KEEP entries"
);
assert_eq!(
reopened.recent(1, None).0[0].target,
format!("Acct {}", COMPACT_AT + 4)
);
}
#[test]
fn clips_long_and_control_laden_fields() {
let dir = tempdir().unwrap();
let log = AuditLog::open(dir.path());
let long = "x".repeat(400);
let e = log.record("ad\nmin", AuditAction::Login, long.as_str(), "", "");
assert_eq!(e.actor, "admin");
assert_eq!(e.target.chars().count(), MAX_FIELD + 1);
assert!(e.target.ends_with('…'));
}
#[cfg(unix)]
#[test]
fn file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let log = AuditLog::open(dir.path());
log.record("admin", AuditAction::Login, "", "", "");
let mode = fs::metadata(dir.path().join(FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn actions_round_trip_through_serde_as_snake_case() {
let s = serde_json::to_string(&AuditAction::LoginFailed).unwrap();
assert_eq!(s, "\"login_failed\"");
assert_eq!(AuditAction::LoginFailed.as_str(), "login_failed");
let back: AuditAction = serde_json::from_str("\"account_added\"").unwrap();
assert_eq!(back, AuditAction::AccountAdded);
}
}
+2
View File
@@ -7,6 +7,7 @@
//! tested without a socket in sight.
#![forbid(unsafe_code)]
pub mod audit;
pub mod auth;
pub mod crypto;
pub mod error;
@@ -15,6 +16,7 @@ pub mod settings;
pub mod totp;
pub mod vault;
pub use audit::{AuditAction, AuditEntry, AuditLog};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use crypto::{Sealed, VaultKey};
pub use error::{Error, Result};
+2
View File
@@ -30,3 +30,5 @@ workspace = true
[dev-dependencies]
qrcode = { version = "0.14", default-features = false, features = ["image"] }
base64.workspace = true
tower = { version = "0.5", features = ["util"] }
tempfile = "3"
+223 -24
View File
@@ -1,23 +1,29 @@
//! Router, asset serving, and the account / code / settings endpoints.
use crate::auth;
use crate::auth::{self, Caller};
use crate::state::AppState;
use axum::{
extract::{DefaultBodyLimit, Multipart, Path, State},
extract::{DefaultBodyLimit, Multipart, Path, Query, 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 openmfa_core::{unix_now, unix_now_millis, AuditAction, Provider, Settings, Totp};
use serde::Deserialize;
use serde_json::json;
use serde_json::{json, Value};
use std::time::{Duration, Instant};
/// 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;
/// How long a revealed code stays uncovered. Two periods: long enough to
/// type it and to survive a rollover mid-way, short enough that a screen
/// left unattended goes back to dots on its own.
const REVEAL_TTL: Duration = Duration::from_secs(60);
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/api/me", get(auth::api_me))
@@ -32,7 +38,10 @@ pub fn router(state: AppState) -> Router {
"/api/accounts/{id}",
axum::routing::patch(update_account).delete(delete_account),
)
.route("/api/accounts/{id}/reveal", post(reveal_account))
.route("/api/accounts/{id}/hide", post(hide_account))
.route("/api/codes", get(list_codes))
.route("/api/log", get(list_log))
.route(
"/api/qr/decode",
post(decode_qr).layer(DefaultBodyLimit::max(MAX_UPLOAD)),
@@ -48,6 +57,7 @@ pub fn router(state: AppState) -> Router {
.route("/", get(index))
.route("/assets/app.js", get(asset_js))
.route("/assets/app.css", get(asset_css))
.route("/assets/icons.js", get(asset_icons))
.route("/assets/logo.svg", get(asset_logo))
.route("/healthz", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
@@ -97,6 +107,18 @@ async fn asset_css() -> Response {
.into_response()
}
/// The bundled service-icon pack — generated, see `scripts/fetch-icons.py`.
async fn asset_icons() -> Response {
(
[
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(header::CACHE_CONTROL, CACHE),
],
openmfa_webui::icons_js(),
)
.into_response()
}
async fn asset_logo() -> Response {
(
[
@@ -192,7 +214,11 @@ struct ImportBody {
/// 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 {
async fn import_accounts(
State(state): State<AppState>,
caller: Caller,
Json(body): Json<ImportBody>,
) -> Response {
let batch = match da::parse_import(&body.uri) {
Ok(b) => b,
Err(e) => return crate::error_response(&e),
@@ -217,7 +243,18 @@ async fn import_accounts(State(state): State<AppState>, Json(body): Json<ImportB
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(),
Ok(outcome) => {
for a in &outcome.added {
state.audit.record(
caller.name(),
AuditAction::AccountAdded,
&a.issuer,
&a.label,
&caller.ip,
);
}
(StatusCode::CREATED, Json(outcome)).into_response()
}
Err(e) => crate::error_response(&e),
}
}
@@ -234,23 +271,66 @@ struct UpdateBody {
async fn update_account(
State(state): State<AppState>,
caller: Caller,
Path(id): Path<String>,
Json(body): Json<UpdateBody>,
) -> Response {
let before = state.vault.get(&id);
match state.vault.update(
&id,
body.issuer.as_deref(),
body.label.as_deref(),
body.push_enabled,
) {
Ok(a) => Json(a).into_response(),
Ok(a) => {
// Say what changed, so the log reads "renamed from X" rather than
// an unhelpful "updated".
let mut changes = Vec::new();
if let Some(b) = &before {
if b.issuer != a.issuer {
changes.push(format!("renamed from {}", b.issuer));
}
if b.label != a.label {
changes.push(format!("account changed from {}", b.label));
}
if b.push_enabled != a.push_enabled {
changes.push(if a.push_enabled {
"push on".to_string()
} else {
"push off".to_string()
});
}
}
state.audit.record(
caller.name(),
AuditAction::AccountUpdated,
&a.issuer,
changes.join(", "),
&caller.ip,
);
Json(a).into_response()
}
Err(e) => crate::error_response(&e),
}
}
async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -> Response {
async fn delete_account(
State(state): State<AppState>,
caller: Caller,
Path(id): Path<String>,
) -> Response {
let gone = state.vault.get(&id);
match state.vault.remove(&id) {
Ok(message_id) => {
if let Some(g) = &gone {
state.audit.record(
caller.name(),
AuditAction::AccountDeleted,
&g.issuer,
&g.label,
&caller.ip,
);
}
// 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 {
@@ -269,28 +349,45 @@ fn default_true() -> bool {
// ── Codes ─────────────────────────────────────────────────────────────────
/// Current and next code for every account, plus the server clock.
/// One account's current and next code, plus when the reveal lapses.
///
/// 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
/// so the digits never blank out.
fn code_json(id: &str, totp: &Totp, now: u64, until: Instant) -> Value {
let remaining = until.saturating_duration_since(Instant::now());
json!({
"id": 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,
"revealed_until": unix_now_millis() + remaining.as_millis() as u64,
})
}
/// Codes for the accounts this session has revealed, plus the server clock.
///
/// Everything else stays covered: the browser never receives a code it has
/// not asked for by name, so "hidden" means hidden and every code that was
/// ever shown has a line in the log. 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 {
async fn list_codes(State(state): State<AppState>, caller: Caller) -> Response {
let revealed = caller
.token
.as_deref()
.map(|t| state.sessions.revealed(t))
.unwrap_or_default();
let now = unix_now();
let codes: Vec<_> = state
let codes: Vec<Value> = 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,
})
.filter_map(|(account, totp)| {
let until = revealed.get(&account.id)?;
Some(code_json(&account.id, &totp, now, *until))
})
.collect();
@@ -301,6 +398,79 @@ async fn list_codes(State(state): State<AppState>) -> Response {
.into_response()
}
/// Uncover one account's code for this session and write that down.
async fn reveal_account(
State(state): State<AppState>,
caller: Caller,
Path(id): Path<String>,
) -> Response {
let Some(account) = state.vault.get(&id) else {
return crate::error_response(&da::Error::not_found("account"));
};
let until = caller
.token
.as_deref()
.and_then(|t| state.sessions.reveal(t, &id, REVEAL_TTL));
let Some(until) = until else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response();
};
let totp = match state.vault.totp_for(&id) {
Ok(t) => t,
Err(e) => return crate::error_response(&e),
};
state.audit.record(
caller.name(),
AuditAction::Reveal,
&account.issuer,
&account.label,
&caller.ip,
);
(
[(header::CACHE_CONTROL, "no-store")],
Json(code_json(&id, &totp, unix_now(), until)),
)
.into_response()
}
/// Cover a code again before its window lapses. Not logged: nothing was
/// disclosed by hiding.
async fn hide_account(
State(state): State<AppState>,
caller: Caller,
Path(id): Path<String>,
) -> Response {
if let Some(t) = caller.token.as_deref() {
state.sessions.hide(t, &id);
}
StatusCode::NO_CONTENT.into_response()
}
// ── Log ───────────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct LogQuery {
#[serde(default)]
limit: Option<usize>,
/// Entry id; only older entries are returned. From the previous page's
/// last entry.
#[serde(default)]
before: Option<u64>,
}
async fn list_log(State(state): State<AppState>, Query(q): Query<LogQuery>) -> Response {
let limit = q.limit.unwrap_or(100).clamp(1, 500);
let (entries, has_more) = state.audit.recent(limit, q.before);
(
[(header::CACHE_CONTROL, "no-store")],
Json(json!({ "entries": entries, "has_more": has_more })),
)
.into_response()
}
// ── QR ────────────────────────────────────────────────────────────────────
/// Decode an uploaded image and return the first `otpauth://` payload.
@@ -376,7 +546,11 @@ async fn get_settings(State(state): State<AppState>) -> Response {
.into_response()
}
async fn put_settings(State(state): State<AppState>, Json(incoming): Json<Settings>) -> Response {
async fn put_settings(
State(state): State<AppState>,
caller: Caller,
Json(incoming): Json<Settings>,
) -> Response {
match state.settings.replace(incoming) {
Ok((s, destination_changed)) => {
if destination_changed {
@@ -393,15 +567,40 @@ async fn put_settings(State(state): State<AppState>, Json(incoming): Json<Settin
"destination changed; every tile will be reposted"
);
}
let mut detail = format!("push {}", if s.push_enabled { "on" } else { "off" });
if destination_changed {
detail.push_str(", destination changed");
}
state.audit.record(
caller.name(),
AuditAction::SettingsUpdated,
s.provider.map_or("no platform", Provider::display_name),
detail,
&caller.ip,
);
Json(s).into_response()
}
Err(e) => crate::error_response(&e),
}
}
async fn test_delivery(State(state): State<AppState>) -> Response {
async fn test_delivery(State(state): State<AppState>, caller: Caller) -> Response {
match state.engine.send_test().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Ok(()) => {
let provider = state
.settings
.snapshot()
.provider
.map_or("no platform", Provider::display_name);
state.audit.record(
caller.name(),
AuditAction::TestMessage,
provider,
"",
&caller.ip,
);
StatusCode::NO_CONTENT.into_response()
}
Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
}
}
+215 -12
View File
@@ -14,18 +14,20 @@
use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
extract::{ConnectInfo, FromRequestParts, Request, State},
http::{header, request::Parts, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use openmfa_core as da;
use openmfa_core::AdminPublic;
use openmfa_core::{AdminPublic, AuditAction};
use parking_lot::RwLock;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::convert::Infallible;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -38,6 +40,9 @@ pub const SESSION_COOKIE: &str = "openmfa_session";
struct Session {
username: String,
expires_at: Instant,
/// Accounts whose codes this session may currently see, and until when.
/// Codes are covered by default; a reveal opens one for a short window.
revealed: HashMap<String, Instant>,
}
/// In-memory session table. Sessions deliberately do not survive a restart —
@@ -58,6 +63,7 @@ impl SessionStore {
Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
revealed: HashMap::new(),
},
);
id
@@ -80,6 +86,46 @@ impl SessionStore {
self.inner.write().remove(id);
}
/// Name on a token without extending its idle timer.
pub fn peek(&self, id: &str) -> Option<String> {
let g = self.inner.read();
let s = g.get(id)?;
(s.expires_at > Instant::now()).then(|| s.username.clone())
}
/// Let this session see one account's code for `ttl`. Returns the
/// deadline, or `None` when the token is not a live session.
pub fn reveal(&self, id: &str, account_id: &str, ttl: Duration) -> Option<Instant> {
let mut g = self.inner.write();
let s = g.get_mut(id)?;
if s.expires_at <= Instant::now() {
g.remove(id);
return None;
}
let until = Instant::now() + ttl;
s.revealed.insert(account_id.to_string(), until);
Some(until)
}
/// Cover a code again ahead of its deadline.
pub fn hide(&self, id: &str, account_id: &str) {
if let Some(s) = self.inner.write().get_mut(id) {
s.revealed.remove(account_id);
}
}
/// Accounts this session may see codes for right now, with deadlines.
/// Expired reveals are dropped on the way out.
pub fn revealed(&self, id: &str) -> HashMap<String, Instant> {
let mut g = self.inner.write();
let Some(s) = g.get_mut(id) else {
return HashMap::new();
};
let now = Instant::now();
s.revealed.retain(|_, until| *until > now);
s.revealed.clone()
}
/// Invalidate everything — used after a credential change so a cookie
/// minted under the old password stops working.
pub fn revoke_all(&self) {
@@ -161,6 +207,65 @@ pub async fn require_auth(
.into_response()
}
// ── Caller ────────────────────────────────────────────────────────────────
/// Who is asking, and from where — what the audit log writes down.
#[derive(Debug, Clone)]
pub struct Caller {
pub token: Option<String>,
pub username: Option<String>,
pub ip: String,
}
impl Caller {
pub fn name(&self) -> &str {
self.username.as_deref().unwrap_or("unknown")
}
}
impl FromRequestParts<AppState> for Caller {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Infallible> {
let token = parse_cookie(&parts.headers);
let username = token.as_deref().and_then(|t| state.sessions.peek(t));
let peer = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0);
Ok(Self {
token,
username,
ip: client_ip(&parts.headers, peer),
})
}
}
/// The address to write in the log.
///
/// A reverse proxy (Nginx Proxy Manager, SWAG, a Cloudflare tunnel) puts the
/// real client in `X-Forwarded-For` / `X-Real-IP`; those win when they carry
/// a parseable address. Otherwise it is the TCP peer — which, inside Docker's
/// bridge network, is the gateway rather than the LAN client. The headers are
/// trusted as-is: the only party who can send them straight to this port is
/// the signed-in admin, and a log they could lie to about their own address
/// is not worth a configuration knob.
pub fn client_ip(headers: &axum::http::HeaderMap, peer: Option<SocketAddr>) -> String {
for name in ["x-forwarded-for", "x-real-ip"] {
let Some(v) = headers.get(name).and_then(|v| v.to_str().ok()) else {
continue;
};
if let Some(ip) = v
.split(',')
.map(str::trim)
.find_map(|s| s.parse::<IpAddr>().ok())
{
return ip.to_string();
}
}
peer.map_or_else(|| "unknown".to_string(), |p| p.ip().to_string())
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
@@ -181,7 +286,11 @@ pub struct SetupBody {
/// 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 {
pub async fn api_setup(
State(state): State<AppState>,
caller: Caller,
Json(body): Json<SetupBody>,
) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
@@ -218,6 +327,9 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
if let Err(e) = state.settings.replace(checked) {
tracing::warn!(target: "openmfa::http", "could not store delivery settings at setup: {e}");
}
state
.audit
.record(&public.username, AuditAction::Setup, "", "", &caller.ip);
let token = state.sessions.create(&public.username);
session_response(StatusCode::CREATED, &public, &token)
}
@@ -231,30 +343,53 @@ pub struct LoginBody {
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
pub async fn api_login(
State(state): State<AppState>,
caller: Caller,
Json(body): Json<LoginBody>,
) -> Response {
let admin = state.admin.clone();
let attempted = body.username.trim().to_string();
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)) => {
state
.audit
.record(&public.username, AuditAction::Login, "", "", &caller.ip);
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(),
// "wrong password", so the endpoint can't be used to enumerate. The
// attempt is logged under the name that was tried.
Ok(None) => {
state
.audit
.record(&attempted, AuditAction::LoginFailed, "", "", &caller.ip);
(
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 {
pub async fn api_logout(
State(state): State<AppState>,
caller: Caller,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(t) = parse_cookie(&headers) {
if let Some(name) = &caller.username {
state
.audit
.record(name, AuditAction::Logout, "", "", &caller.ip);
}
state.sessions.revoke(&t);
}
let mut resp = StatusCode::NO_CONTENT.into_response();
@@ -308,9 +443,16 @@ pub struct UpdateCredentialsBody {
/// the keyboard is the owner and not someone using a borrowed browser.
pub async fn api_update_credentials(
State(state): State<AppState>,
caller: Caller,
Json(body): Json<UpdateCredentialsBody>,
) -> Response {
let admin = state.admin.clone();
let what = match (body.new_username.is_some(), body.new_password.is_some()) {
(true, true) => "username and password",
(true, false) => "username",
(false, true) => "password",
(false, false) => "nothing",
};
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
@@ -323,6 +465,13 @@ pub async fn api_update_credentials(
match result {
Ok(public) => {
state.audit.record(
&public.username,
AuditAction::CredentialsUpdated,
"",
what,
&caller.ip,
);
// Every prior cookie dies with the old password.
state.sessions.revoke_all();
let token = state.sessions.create(&public.username);
@@ -411,6 +560,60 @@ mod tests {
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
#[test]
fn reveals_are_per_session_and_expire() {
let s = SessionStore::default();
let t = s.create("admin");
assert!(s.revealed(&t).is_empty(), "nothing is visible by default");
let until = s.reveal(&t, "acct-1", Duration::from_secs(60)).unwrap();
assert!(until > Instant::now());
assert!(s.revealed(&t).contains_key("acct-1"));
// A different session sees nothing.
let other = s.create("admin");
assert!(s.revealed(&other).is_empty());
// Hide is immediate.
s.hide(&t, "acct-1");
assert!(s.revealed(&t).is_empty());
// A zero TTL is expired on the next read.
s.reveal(&t, "acct-2", Duration::ZERO).unwrap();
assert!(s.revealed(&t).is_empty());
// Dead tokens cannot reveal.
assert!(s.reveal("nope", "acct-1", Duration::from_secs(1)).is_none());
}
#[test]
fn client_ip_prefers_a_forwarded_address_then_falls_back_to_the_peer() {
let peer: SocketAddr = "172.17.0.1:54321".parse().unwrap();
let mut h = axum::http::HeaderMap::new();
assert_eq!(client_ip(&h, Some(peer)), "172.17.0.1");
assert_eq!(client_ip(&h, None), "unknown");
h.insert("x-real-ip", HeaderValue::from_static("192.168.1.123"));
assert_eq!(client_ip(&h, Some(peer)), "192.168.1.123");
// X-Forwarded-For wins over X-Real-IP, and its first hop is the client.
h.insert(
"x-forwarded-for",
HeaderValue::from_static("203.0.113.9, 10.0.0.2"),
);
assert_eq!(client_ip(&h, Some(peer)), "203.0.113.9");
// Garbage in the header is not copied into the log.
h.insert(
"x-forwarded-for",
HeaderValue::from_static("<script>, 10.0.0.2"),
);
assert_eq!(client_ip(&h, Some(peer)), "10.0.0.2");
h.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip"));
h.remove("x-real-ip");
assert_eq!(client_ip(&h, Some(peer)), "172.17.0.1");
}
#[test]
fn logout_cookie_expires_immediately() {
assert!(cookie("", 0).contains("Max-Age=0"));
+2 -1
View File
@@ -1,6 +1,6 @@
//! Shared handler state.
use openmfa_core::{AdminStore, SettingsStore, Vault};
use openmfa_core::{AdminStore, AuditLog, SettingsStore, Vault};
use openmfa_delivery::PushEngine;
use std::sync::Arc;
@@ -13,6 +13,7 @@ pub struct AppState {
pub settings: Arc<SettingsStore>,
pub sessions: SessionStore,
pub engine: PushEngine,
pub audit: AuditLog,
pub version: &'static str,
}
+290
View File
@@ -0,0 +1,290 @@
//! Through the router: codes stay covered until a reveal, reveals are scoped
//! to the session that asked, and everything the server did is in the log
//! with the caller's address.
use axum::body::Body;
use axum::http::{header, HeaderMap, Request, StatusCode};
use openmfa_core::{AdminStore, AuditLog, SettingsStore, Vault, VaultKey};
use openmfa_delivery::{Endpoints, PushEngine};
use openmfa_http_api::{auth::SessionStore, router, AppState};
use serde_json::{json, Value};
use tower::ServiceExt;
const URI: &str = "otpauth://totp/GitHub:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=GitHub";
const CLIENT: &str = "203.0.113.9";
fn app(dir: &std::path::Path) -> axum::Router {
let key = VaultKey::load_or_create(dir).unwrap();
let vault = Vault::load(dir, key).unwrap();
let settings = SettingsStore::load_or_default(dir);
let engine = PushEngine::new(vault.clone(), settings.clone(), &Endpoints::from_env());
router(AppState {
admin: AdminStore::load_or_default(dir),
vault,
settings,
sessions: SessionStore::default(),
engine,
audit: AuditLog::open(dir),
version: "test",
})
}
async fn call(
app: &axum::Router,
method: &str,
path: &str,
cookie: Option<&str>,
body: Option<Value>,
) -> (StatusCode, HeaderMap, Value) {
let mut req = Request::builder()
.method(method)
.uri(path)
// What a reverse proxy would add: client first, then the hop.
.header("x-forwarded-for", format!("{CLIENT}, 10.0.0.2"));
if let Some(c) = cookie {
req = req.header(header::COOKIE, format!("openmfa_session={c}"));
}
let req = match body {
Some(b) => req
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(b.to_string()))
.unwrap(),
None => req.body(Body::empty()).unwrap(),
};
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
};
(status, headers, value)
}
fn session_cookie(h: &HeaderMap) -> String {
let raw = h.get(header::SET_COOKIE).unwrap().to_str().unwrap();
raw.split(';')
.next()
.unwrap()
.strip_prefix("openmfa_session=")
.unwrap()
.to_string()
}
async fn set_up(app: &axum::Router) -> String {
let (status, headers, _) = call(
app,
"POST",
"/api/setup",
None,
Some(json!({ "username": "admin", "password": "correct horse battery" })),
)
.await;
assert_eq!(status, StatusCode::CREATED);
session_cookie(&headers)
}
async fn import_github(app: &axum::Router, cookie: &str) -> String {
let (status, _, out) = call(
app,
"POST",
"/api/accounts/import",
Some(cookie),
Some(json!({ "uri": URI, "items": [{ "index": 0 }] })),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{out}");
out["added"][0]["id"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn codes_stay_covered_until_revealed_and_the_reveal_is_logged() {
let dir = tempfile::tempdir().unwrap();
let app = app(dir.path());
let cookie = set_up(&app).await;
let id = import_github(&app, &cookie).await;
// Nothing is handed out unasked.
let (status, _, codes) = call(&app, "GET", "/api/codes", Some(&cookie), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(codes["codes"].as_array().unwrap().len(), 0);
// Reveal returns the code and opens the window for this session.
let (status, _, revealed) = call(
&app,
"POST",
&format!("/api/accounts/{id}/reveal"),
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "{revealed}");
let code = revealed["code"].as_str().unwrap();
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_digit()));
assert!(
revealed["revealed_until"].as_u64().unwrap()
> revealed["expires_at"].as_u64().unwrap() - 60_000
);
let (_, _, codes) = call(&app, "GET", "/api/codes", Some(&cookie), None).await;
let list = codes["codes"].as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["id"], id);
assert_eq!(list[0]["code"], code);
// Another session of the same admin sees dots.
let (status, headers, _) = call(
&app,
"POST",
"/api/login",
None,
Some(json!({ "username": "admin", "password": "correct horse battery" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let other = session_cookie(&headers);
let (_, _, codes) = call(&app, "GET", "/api/codes", Some(&other), None).await;
assert_eq!(codes["codes"].as_array().unwrap().len(), 0);
// Hide covers it again at once.
let (status, _, _) = call(
&app,
"POST",
&format!("/api/accounts/{id}/hide"),
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (_, _, codes) = call(&app, "GET", "/api/codes", Some(&cookie), None).await;
assert_eq!(codes["codes"].as_array().unwrap().len(), 0);
// The log has the whole story, newest first, with the forwarded address.
let (status, _, log) = call(&app, "GET", "/api/log?limit=50", Some(&cookie), None).await;
assert_eq!(status, StatusCode::OK);
let entries = log["entries"].as_array().unwrap();
let actions: Vec<&str> = entries
.iter()
.map(|e| e["action"].as_str().unwrap())
.collect();
assert_eq!(actions, ["login", "reveal", "account_added", "setup"]);
let reveal = &entries[1];
assert_eq!(reveal["actor"], "admin");
assert_eq!(reveal["target"], "GitHub");
assert_eq!(reveal["detail"], "[email protected]");
assert_eq!(reveal["ip"], CLIENT);
assert!(reveal["ts"].as_u64().unwrap() > 1_700_000_000_000);
assert_eq!(log["has_more"], false);
// Paging backwards from the reveal gives what came before it.
let before = reveal["id"].as_u64().unwrap();
let (_, _, older) = call(
&app,
"GET",
&format!("/api/log?limit=10&before={before}"),
Some(&cookie),
None,
)
.await;
let older: Vec<&str> = older["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["action"].as_str().unwrap())
.collect();
assert_eq!(older, ["account_added", "setup"]);
}
#[tokio::test]
async fn the_log_and_reveals_need_a_session() {
let dir = tempfile::tempdir().unwrap();
let app = app(dir.path());
let cookie = set_up(&app).await;
let id = import_github(&app, &cookie).await;
let (status, _, _) = call(&app, "GET", "/api/log", None, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, _) = call(
&app,
"POST",
&format!("/api/accounts/{id}/reveal"),
None,
None,
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
// A reveal of something that doesn't exist is a 404, not a log entry.
let (status, _, _) = call(
&app,
"POST",
"/api/accounts/nope/reveal",
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (_, _, log) = call(&app, "GET", "/api/log", Some(&cookie), None).await;
assert!(log["entries"]
.as_array()
.unwrap()
.iter()
.all(|e| e["action"] != "reveal"));
}
#[tokio::test]
async fn failed_sign_ins_and_account_changes_are_logged() {
let dir = tempfile::tempdir().unwrap();
let app = app(dir.path());
let cookie = set_up(&app).await;
let id = import_github(&app, &cookie).await;
let (status, _, _) = call(
&app,
"POST",
"/api/login",
None,
Some(json!({ "username": " Mallory ", "password": "wrong" })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, _) = call(
&app,
"PATCH",
&format!("/api/accounts/{id}"),
Some(&cookie),
Some(json!({ "issuer": "GitHub Enterprise", "push_enabled": false })),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _, _) = call(
&app,
"DELETE",
&format!("/api/accounts/{id}"),
Some(&cookie),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (_, _, log) = call(&app, "GET", "/api/log", Some(&cookie), None).await;
let entries = log["entries"].as_array().unwrap();
assert_eq!(entries[0]["action"], "account_deleted");
assert_eq!(entries[0]["target"], "GitHub Enterprise");
assert_eq!(entries[1]["action"], "account_updated");
assert_eq!(entries[1]["detail"], "renamed from GitHub, push off");
assert_eq!(entries[2]["action"], "login_failed");
assert_eq!(
entries[2]["actor"], "Mallory",
"the attempted name, trimmed"
);
assert_eq!(entries[2]["ip"], CLIENT);
}
+12 -5
View File
@@ -4,7 +4,7 @@
use anyhow::Context;
use clap::Parser;
use openmfa_core::{AdminStore, SettingsStore, Vault, VaultKey};
use openmfa_core::{AdminStore, AuditLog, SettingsStore, Vault, VaultKey};
use openmfa_delivery::{Endpoints, PushEngine};
use openmfa_http_api::AppState;
use std::net::SocketAddr;
@@ -56,6 +56,7 @@ async fn main() -> anyhow::Result<()> {
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 audit = AuditLog::open(&args.work_dir);
let engine = PushEngine::new(vault.clone(), settings.clone(), &Endpoints::from_env());
tokio::spawn(engine.clone().run());
@@ -66,6 +67,7 @@ async fn main() -> anyhow::Result<()> {
settings,
sessions: openmfa_http_api::auth::SessionStore::default(),
engine,
audit,
version: VERSION,
};
@@ -88,10 +90,15 @@ async fn main() -> anyhow::Result<()> {
);
}
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("the HTTP server stopped unexpectedly")?;
// `with_connect_info` is what gives handlers the peer address for the
// audit log when no proxy is forwarding one.
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.context("the HTTP server stopped unexpectedly")?;
tracing::info!(target: "openmfa", "shutting down");
Ok(())
+46 -6
View File
@@ -431,6 +431,14 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
box-shadow: inset 0 0 0 1px var(--line-strong);
user-select: none;
}
/* A recognised service shows its mark in the brand colour. Marks that are
near-black or near-white would vanish against one of the two tiles, so
those fall back to ink for that theme only. */
.avatar.brand { background: var(--surface); }
.avatar.brand svg { width: 20px; height: 20px; fill: var(--brand, currentColor); display: block; }
:root[data-theme="dark"] .avatar.brand[data-tone="dark"] svg { fill: currentColor; }
:root[data-theme="light"] .avatar.brand[data-tone="light"] svg { fill: currentColor; }
.import-row .avatar.brand svg { width: 16px; height: 16px; }
.acct .meta { min-width: 0; }
.acct .issuer {
@@ -462,6 +470,16 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
}
.code:hover { background: var(--hover-2); }
.code .gap { display: inline-block; width: 0.42em; }
/* Covered: the same footprint as the digits, so rows don't jump on reveal. */
.code.covered {
display: inline-flex; align-items: center; gap: 9px;
color: var(--ink-3);
letter-spacing: 0.1em;
}
.code.covered svg { width: 17px; height: 17px; color: var(--ink-2); flex: none; }
.code.covered:hover { color: var(--ink-2); }
.code.covered:hover svg { color: var(--ink); }
/* Last 5 seconds: the code is about to roll. */
.acct.expiring .code { color: var(--orange); }
@@ -671,12 +689,6 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
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; }
@@ -812,6 +824,34 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
/* ── Utility ──────────────────────────────────────────────────────────── */
/* ── Log ──────────────────────────────────────────────────────────────── */
.log-list { display: flex; flex-direction: column; }
.log-row {
display: grid; grid-template-columns: 28px 1fr; gap: 12px;
padding: 11px 0;
}
.log-row + .log-row { border-top: 1px solid var(--line-soft); }
.log-glyph {
display: grid; place-items: center;
width: 28px; height: 28px; margin-top: 1px;
border-radius: 8px;
color: var(--ink-2);
background: var(--inset);
box-shadow: inset 0 0 0 1px var(--line);
}
.log-glyph svg { width: 14px; height: 14px; }
.log-body { min-width: 0; }
.log-line { font-size: 13.5px; line-height: 1.45; overflow-wrap: anywhere; }
.log-meta {
display: flex; flex-wrap: wrap; gap: 0 9px;
margin-top: 3px;
font-size: 12px; color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.log-meta span + span::before { content: "|"; margin-right: 9px; color: var(--line-strong); }
.log-empty { padding: 26px 0; text-align: center; font-size: 13px; color: var(--ink-3); }
.mono { font-family: var(--mono); }
.muted { color: var(--ink-2); }
.small { font-size: 12.5px; }
+251 -43
View File
@@ -90,6 +90,10 @@ const ICON = {
key: ['M12.7 11.3a5 5 0 1 0-4 4L10 14h2v-2h2l-1.3-1.3Z'],
send: ['M22 2 11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'],
eye: ['M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'],
eyeOff: ['M17.9 17.9A10 10 0 0 1 12 19c-6.5 0-10-7-10-7a17 17 0 0 1 4.1-4.9', 'M9.9 5.2A9.5 9.5 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-2 2.9', 'M14.1 14.1a3 3 0 1 1-4.2-4.2', 'M2 2l20 20'],
login: ['M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'M10 17l5-5-5-5', 'M15 12H3'],
list: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'],
};
// ── State ──────────────────────────────────────────────────────────────────
@@ -97,7 +101,7 @@ const ICON = {
const state = {
me: null, // /api/me payload
accounts: [], // account metadata
codes: new Map(), // id -> { code, next, period, digits }
codes: new Map(), // id -> { code, next, period, expires_at, revealed_until } — revealed accounts only
clockOffsetMs: 0, // serverNow - clientNow
query: '',
view: 'vault',
@@ -162,6 +166,66 @@ function initials(str) {
return (s[0] || '?').toUpperCase();
}
/* ── Service icons ────────────────────────────────────────────────────────
* The pack in icons.js (generated; Simple Icons + a few Font Awesome brands)
* is keyed by slug and comes with an alias table. An otpauth:// link carries
* no artwork — only issuer, label and the secret — so the match is by name:
* the whole issuer, then shorter word-prefixes of it ("Google Workspace" →
* "google"), then the label's mail domain ("[email protected]" → gmail). Anything
* unmatched falls back to the initial. */
function iconKey(s) {
return String(s || '').toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '');
}
function serviceIcon(issuer, label) {
const icons = window.OPENMFA_ICONS || {};
const aliases = window.OPENMFA_ICON_ALIASES || {};
const tries = [];
const whole = iconKey(issuer);
if (whole) tries.push(whole);
const words = String(issuer || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
for (let n = words.length - 1; n >= 1; n--) tries.push(words.slice(0, n).join(''));
const at = String(label || '').indexOf('@');
if (at >= 0) {
const domain = String(label).slice(at + 1).toLowerCase();
tries.push(iconKey(domain), iconKey(domain.split('.')[0]));
}
for (const key of tries) {
if (!key) continue;
const slug = icons[key] ? key : aliases[key];
if (slug && icons[slug]) {
const [title, viewBox, paths, color] = icons[slug];
return { slug, title, viewBox, paths, color };
}
}
return null;
}
/** Relative luminance of a #rrggbb colour, 0..1 — decides whether a brand
* colour would vanish against the current theme's tile. */
function luminance(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return 0.5;
const n = parseInt(m[1], 16);
const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
return 0.2126 * lin(n >> 16) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
}
/** The initials tile, upgraded to the service's mark when we have one. */
function avatarFor(issuer, label) {
const ic = serviceIcon(issuer, label);
if (!ic) return h('div.avatar', { text: initials(issuer || label) });
const L = luminance(ic.color);
const svg = icon(ic.paths, { viewBox: ic.viewBox, fill: 'currentColor', stroke: 'none' });
svg.removeAttribute('stroke-width');
return h('div.avatar.brand', {
title: ic.title,
style: '--brand:' + ic.color,
dataset: { tone: L < 0.12 ? 'dark' : L > 0.7 ? 'light' : 'mid' },
}, [svg]);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
@@ -241,9 +305,9 @@ function brandMark(size) {
// ── Delivery platforms ───────────────────────────────────────────────────────
const PROVIDERS = [
{ id: 'discord', name: 'Discord', color: '#5865F2' },
{ id: 'slack', name: 'Slack', color: '#2EB67D' },
{ id: 'telegram', name: 'Telegram', color: '#229ED9' },
{ id: 'discord', name: 'Discord' },
{ id: 'slack', name: 'Slack' },
{ id: 'telegram', name: 'Telegram' },
];
function providerName(id) {
@@ -257,7 +321,7 @@ function providerPicker(initial, onChange) {
const buttons = PROVIDERS.map((p) => h('button', {
type: 'button', dataset: { provider: p.id },
onclick: () => { current = current === p.id ? null : p.id; paint(); onChange(current); },
}, [h('span.dot', { style: 'background:' + p.color }), p.name]));
}, [p.name]));
const el = h('div.segmented', {}, buttons);
function paint() { for (const b of buttons) b.classList.toggle('active', b.dataset.provider === current); }
paint();
@@ -521,6 +585,7 @@ function toggleTheme() {
function renderView() {
const el = document.getElementById('view');
if (!el) return;
if (logClock) { clearInterval(logClock); logClock = null; }
const fab = document.querySelector('.fab');
if (fab) fab.remove();
if (state.view === 'settings') el.replaceChildren(...settingsView());
@@ -571,15 +636,61 @@ function vaultView() {
}
function acctRow(a) {
const pushDot = h('span.push-dot', {
title: pushTitle(a),
class: 'push-dot ' + pushClass(a),
});
const slot = h('div.right', { dataset: { slot: a.id } });
paintSlot(slot, a);
return h('div.acct', { dataset: { acct: a.id } }, [
avatarFor(a.issuer, a.label),
h('div.meta', {}, [
h('div.issuer', { text: a.issuer || 'Unnamed' }),
h('div.label', {}, [
pushDot,
h('span', { text: ' ' + (a.label || '—') }),
]),
]),
slot,
]);
}
/* Codes are covered until asked for. The right-hand slot of a row has two
* shapes — dots with an eye, or the live digits with a copy button and the
* countdown ring — and is only rebuilt when it changes shape; the ticker
* updates digits and ring in place. */
function paintSlot(slot, a) {
const c = state.codes.get(a.id);
const mode = c ? 'open' : 'covered';
if (slot.dataset.mode === mode) return;
slot.dataset.mode = mode;
const editBtn = h('button.icon-btn', {
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
onclick: () => openAccountSheet(a),
}, [icon(ICON.edit, { width: 1.9 })]);
if (!c) {
const reveal = h('button.code.covered', {
type: 'button', title: 'Reveal code', 'aria-label': `Reveal ${a.issuer} code`,
onclick: () => revealAccount(a),
}, [h('span.dots', { text: '••• •••' }), icon(ICON.eye, { width: 1.9 })]);
slot.replaceChildren(reveal, editBtn);
return;
}
const codeBtn = h('button.code', {
type: 'button', title: 'Click to copy', dataset: { code: a.id },
}, ['······']);
codeBtn.addEventListener('click', async () => {
const c = state.codes.get(a.id);
if (!c) return;
const ok = await copyText(c.code);
toast(ok ? `Copied ${a.issuer} code` : 'Could not copy', ok ? 'ok' : 'err');
});
onclick: () => copyCode(a),
}, codeSpans(c.code));
codeBtn.dataset.shown = c.code;
const copyBtn = h('button.icon-btn', {
type: 'button', 'aria-label': `Copy ${a.issuer} code`, title: 'Copy',
onclick: () => copyCode(a),
}, [icon(ICON.copy, { width: 1.9 })]);
const ring = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
ring.setAttribute('class', 'ring');
@@ -589,32 +700,27 @@ function acctRow(a) {
'<circle class="fg" cx="13" cy="13" r="11"></circle>';
ring.dataset.ring = a.id;
const avatar = h('div.avatar', { text: initials(a.issuer || a.label) });
slot.replaceChildren(codeBtn, copyBtn, ring, editBtn);
}
const pushDot = h('span.push-dot', {
title: pushTitle(a),
class: 'push-dot ' + pushClass(a),
});
async function revealAccount(a) {
try {
const c = await api('/api/accounts/' + encodeURIComponent(a.id) + '/reveal', { method: 'POST' });
state.codes.set(a.id, {
code: c.code, next: c.next, period: c.period,
expires_at: c.expires_at, revealed_until: c.revealed_until,
});
const slot = document.querySelector(`.right[data-slot="${cssEscape(a.id)}"]`);
if (slot) paintSlot(slot, a);
tick();
} catch (e) { toast(e.message, 'err'); }
}
const row = h('div.acct', { dataset: { acct: a.id } }, [
avatar,
h('div.meta', {}, [
h('div.issuer', { text: a.issuer || 'Unnamed' }),
h('div.label', {}, [
pushDot,
h('span', { text: ' ' + (a.label || '—') }),
]),
]),
h('div.right', {}, [
codeBtn,
ring,
h('button.icon-btn', {
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
onclick: () => openAccountSheet(a),
}, [icon(ICON.edit, { width: 1.9 })]),
]),
]);
return row;
async function copyCode(a) {
const c = state.codes.get(a.id);
if (!c) return;
const ok = await copyText(c.code);
toast(ok ? `Copied ${a.issuer} code` : 'Could not copy', ok ? 'ok' : 'err');
}
function pushClass(a) {
@@ -649,7 +755,11 @@ function tick() {
for (const a of state.accounts) {
const c = state.codes.get(a.id);
if (!c) continue;
// A reveal lapses on its own: back to dots, no request needed.
if (c && c.revealed_until && t >= c.revealed_until) state.codes.delete(a.id);
const slot = document.querySelector(`.right[data-slot="${cssEscape(a.id)}"]`);
if (slot) paintSlot(slot, a);
if (!c || !state.codes.has(a.id)) continue;
const period = (c.period || 30) * 1000;
// Roll over locally the instant the period boundary passes — the next
@@ -712,15 +822,16 @@ async function refreshCodes() {
const rtt = Date.now() - t0;
// Assume the response was generated at the midpoint of the round trip.
state.clockOffsetMs = data.server_time - (t0 + rtt / 2);
// Only revealed accounts come back; anything else goes back under cover.
const seen = new Set();
for (const c of data.codes) {
seen.add(c.id);
state.codes.set(c.id, {
code: c.code, next: c.next, period: c.period,
expires_at: c.expires_at,
expires_at: c.expires_at, revealed_until: c.revealed_until,
});
}
// Drop codes for accounts that no longer exist.
const live = new Set(state.accounts.map((a) => a.id));
for (const id of [...state.codes.keys()]) if (!live.has(id)) state.codes.delete(id);
for (const id of [...state.codes.keys()]) if (!seen.has(id)) state.codes.delete(id);
}
async function reloadAll() {
@@ -1099,7 +1210,7 @@ function openAddSheet() {
boxes.forEach((b) => b.addEventListener('change', sync));
const list = h('div.import-list', {}, rows.map((r, i) => {
const av = h('div.avatar', { text: initials(r.acct.issuer || r.acct.label) });
const av = avatarFor(r.acct.issuer, r.acct.label);
return h('label.import-row', {}, [
boxes[i],
av,
@@ -1230,7 +1341,7 @@ function openAccountSheet(a) {
function settingsView() {
const nav = h('div.settings-nav', {}, [
['delivery', 'Delivery'], ['account', 'Account'], ['about', 'About'],
['delivery', 'Delivery'], ['account', 'Account'], ['log', 'Log'], ['about', 'About'],
].map(([k, lbl]) => h('button.btn' + (state.settingsTab === k ? '.btn-primary' : ''), {
onclick: () => { state.settingsTab = k; renderView(); }, text: lbl,
})));
@@ -1242,6 +1353,7 @@ function settingsView() {
let body;
if (state.settingsTab === 'account') body = accountSettings();
else if (state.settingsTab === 'log') body = logSettings();
else if (state.settingsTab === 'about') body = aboutSettings();
else body = deliverySettings();
@@ -1379,6 +1491,100 @@ function accountSettings() {
]);
}
// ── Log ────────────────────────────────────────────────────────────────────
const LOG_VERBS = {
setup: 'created the admin account',
login: 'signed in',
login_failed: 'failed to sign in',
logout: 'signed out',
reveal: 'revealed',
account_added: 'added',
account_updated: 'updated',
account_deleted: 'removed',
settings_updated: 'changed delivery to',
test_message: 'sent a test message to',
credentials_updated: 'changed the admin',
};
const LOG_ICONS = {
setup: 'shield', login: 'login', login_failed: 'alert', logout: 'logout', reveal: 'eye',
account_added: 'plus', account_updated: 'edit', account_deleted: 'trash',
settings_updated: 'settings', test_message: 'send', credentials_updated: 'key',
};
function timeAgo(ts) {
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (s < 45) return 'just now';
const m = Math.round(s / 60);
if (m < 60) return m === 1 ? '1 minute ago' : `${m} minutes ago`;
const hrs = Math.round(m / 60);
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`;
const d = Math.round(hrs / 24);
if (d === 1) return 'yesterday';
if (d < 14) return `${d} days ago`;
return new Date(ts).toLocaleDateString();
}
let logClock = null;
function logRow(e) {
const verb = LOG_VERBS[e.action] || e.action.replace(/_/g, ' ');
const line = [h('strong', { text: e.actor }), ' ' + verb];
if (e.target) line.push(' ', h('strong', { text: e.target }));
if (e.detail) line.push(' ', h('span.muted', { text: e.detail }));
return h('div.log-row', { title: new Date(e.ts).toLocaleString() }, [
h('div.log-glyph', {}, [icon(ICON[LOG_ICONS[e.action]] || ICON.info, { width: 1.9 })]),
h('div.log-body', {}, [
h('div.log-line', {}, line),
h('div.log-meta', {}, [
h('span.ago', { dataset: { ts: e.ts }, text: timeAgo(e.ts) }),
h('span', { text: e.actor }),
h('span', { text: e.ip || 'unknown address' }),
]),
]),
]);
}
/** Settings → Log: what the server did and for whom, newest first. */
function logSettings() {
const list = h('div.log-list');
const older = h('button.btn.hidden', { type: 'button', text: 'Show older' });
let oldest = null;
async function load() {
older.disabled = true;
try {
const q = '/api/log?limit=100' + (oldest ? '&before=' + oldest : '');
const r = await api(q);
for (const e of r.entries) list.append(logRow(e));
if (r.entries.length) oldest = r.entries[r.entries.length - 1].id;
older.classList.toggle('hidden', !r.has_more);
if (!list.children.length) {
list.append(h('div.log-empty', { text: 'Nothing yet. Reveals, sign-ins and changes will show up here.' }));
}
} catch (e) { toast(e.message, 'err'); }
older.disabled = false;
}
older.addEventListener('click', load);
load();
// Keep "3 minutes ago" honest while the tab is open.
if (logClock) clearInterval(logClock);
logClock = setInterval(() => {
for (const el of document.querySelectorAll('.log-meta .ago')) el.textContent = timeAgo(Number(el.dataset.ts));
}, 30000);
return h('div.card', {}, [
h('div.card-head', {}, [
h('div', {}, [
h('h2', { text: 'Log' }),
h('div.card-sub', { text: 'Every code revealed, sign-in and change, with who did it and from where.' }),
]),
]),
h('div.card-body', {}, [list, h('div', { style: 'margin-top:12px' }, [older])]),
]);
}
function aboutSettings() {
const card = h('div.card', {}, [
h('div.card-head', {}, [h('h2', { text: 'About' })]),
@@ -1389,6 +1595,8 @@ function aboutSettings() {
]),
h('p.small.muted', { style: 'margin-top:14px' , text:
'OpenMFA keeps one live message per account in your Discord, Slack or Telegram channel and edits it in place as each code rolls.' }),
h('p.small.muted', { style: 'margin-top:8px', text:
'Service marks come from Simple Icons (CC0) and Font Awesome Free (CC BY 4.0) and remain trademarks of their owners.' }),
]),
]);
return card;
+6
View File
@@ -0,0 +1,6 @@
// GENERATED by scripts/fetch-icons.py — do not edit by hand.
// Simple Icons 16.30.0 (CC0-1.0) and Font Awesome Free 6.7.2 brands
// (CC BY 4.0). Brand marks are trademarks of their respective owners.
// Shape: slug -> [title, viewBox, [path d, ...], brand colour].
window.OPENMFA_ICONS = {"1password":["1Password","0 0 24 24",["M12 0c6.627 0 12 5.373 12 12 0 6.628-5.373 12-12 12S0 18.628 0 12C0 5.373 5.373 0 12 0m-.893 4.86c-.485 0-.727.001-.913.095a.87.87 0 0 0-.378.379c-.094.185-.095.428-.095.912v2.747c0 .12 0 .182.016.238q.02.075.065.138a1 1 0 0 0 .175.162l.695.564c.113.092.17.139.19.194a.22.22 0 0 1 0 .15c-.02.056-.077.102-.19.194l-.695.564a1 1 0 0 0-.175.162.4.4 0 0 0-.065.138 1 1 0 0 0-.016.238v6.019c0 .485 0 .728.095.913a.87.87 0 0 0 .378.378c.186.094.428.094.913.094h1.786c.485 0 .727 0 .913-.094a.87.87 0 0 0 .378-.378c.095-.185.095-.428.095-.913v-2.747c0-.12 0-.182-.016-.238a.4.4 0 0 0-.065-.138 1 1 0 0 0-.175-.162l-.695-.564c-.113-.092-.17-.138-.191-.193a.22.22 0 0 1 0-.152c.02-.055.078-.1.19-.193l.696-.564a1 1 0 0 0 .175-.162.4.4 0 0 0 .065-.138 1 1 0 0 0 .016-.238V6.246c0-.484 0-.727-.095-.912a.87.87 0 0 0-.378-.379c-.186-.094-.428-.094-.913-.094Z"],"#145FE4"],"airbnb":["Airbnb","0 0 24 24",["M12.001 18.275c-1.353-1.697-2.148-3.184-2.413-4.457-.263-1.027-.16-1.848.291-2.465.477-.71 1.188-1.056 2.121-1.056s1.643.345 2.12 1.063c.446.61.558 1.432.286 2.465-.291 1.298-1.085 2.785-2.412 4.458zm9.601 1.14c-.185 1.246-1.034 2.28-2.2 2.783-2.253.98-4.483-.583-6.392-2.704 3.157-3.951 3.74-7.028 2.385-9.018-.795-1.14-1.933-1.695-3.394-1.695-2.944 0-4.563 2.49-3.927 5.382.37 1.565 1.352 3.343 2.917 5.332-.98 1.085-1.91 1.856-2.732 2.333-.636.344-1.245.558-1.828.609-2.679.399-4.778-2.2-3.825-4.88.132-.345.395-.98.845-1.961l.025-.053c1.464-3.178 3.242-6.79 5.285-10.795l.053-.132.58-1.116c.45-.822.635-1.19 1.351-1.643.346-.21.77-.315 1.246-.315.954 0 1.698.558 2.016 1.007.158.239.345.557.582.953l.558 1.089.08.159c2.041 4.004 3.821 7.608 5.279 10.794l.026.025.533 1.22.318.764c.243.613.294 1.222.213 1.858zm1.22-2.39c-.186-.583-.505-1.271-.9-2.094v-.03c-1.889-4.006-3.642-7.608-5.307-10.844l-.111-.163C15.317 1.461 14.468 0 12.001 0c-2.44 0-3.476 1.695-4.535 3.898l-.081.16c-1.669 3.236-3.421 6.843-5.303 10.847v.053l-.559 1.22c-.21.504-.317.768-.345.847C-.172 20.74 2.611 24 5.98 24c.027 0 .132 0 .265-.027h.372c1.75-.213 3.554-1.325 5.384-3.317 1.829 1.989 3.635 3.104 5.382 3.317h.372c.133.027.239.027.265.027 3.37.003 6.152-3.261 4.802-6.975z"],"#FF5A5F"],"airtable":["Airtable","0 0 24 24",["M11.992 1.966c-.434 0-.87.086-1.28.257L1.779 5.917c-.503.208-.49.908.012 1.116l8.982 3.558a3.266 3.266 0 0 0 2.454 0l8.982-3.558c.503-.196.503-.908.012-1.116l-8.957-3.694a3.255 3.255 0 0 0-1.272-.257zM23.4 8.056a.589.589 0 0 0-.222.045l-10.012 3.877a.612.612 0 0 0-.38.564v8.896a.6.6 0 0 0 .821.552L23.62 18.1a.583.583 0 0 0 .38-.551V8.653a.6.6 0 0 0-.6-.596zM.676 8.095a.644.644 0 0 0-.48.19C.086 8.396 0 8.53 0 8.69v8.355c0 .442.515.737.908.54l6.27-3.006.307-.147 2.969-1.436c.466-.22.43-.908-.061-1.092L.883 8.138a.57.57 0 0 0-.207-.044z"],"#18BFFF"],"akamai":["Akamai","0 0 24 24",["M13.0548 0C6.384 0 .961 5.3802.961 12.0078.961 18.6354 6.3698 24 13.0548 24c.6168 0 .6454-.3572.0859-.5293-4.9349-1.5063-8.5352-6.069-8.5352-11.4629 0-5.4656 3.6725-10.0706 8.6934-11.5195C13.8153.3448 13.6716 0 13.0548 0Zm2.3242 1.8223c-5.2648 0-9.5254 4.2606-9.5254 9.5254 0 1.2193.2285 2.3818.6445 3.4433.1722.459.4454.4584.4024.0137-.0287-.3156-.0567-.6447-.0567-.9746 0-5.2648 4.2606-9.5254 9.5254-9.5254 4.9779 0 6.4698 2.2235 6.6563 2.08.2008-.1577-1.808-4.5624-7.6465-4.5624zm.4687 4.0703c-1.8622.0592-3.651.7168-5.1035 1.8554-.2582.2009-.1567.3284.1445.1993 2.4675-1.076 5.5812-1.1046 8.6368-.043 2.0514.7173 3.2413 1.7364 3.3418 1.6934.1578-.0718-1.1915-2.2226-3.6446-3.1407-1.1135-.4196-2.2576-.6-3.375-.5644z"],"#0096D6"],"amazon":["Amazon","0 0 448 512",["M257.2 162.7c-48.7 1.8-169.5 15.5-169.5 117.5 0 109.5 138.3 114 183.5 43.2 6.5 10.2 35.4 37.5 45.3 46.8l56.8-56S341 288.9 341 261.4V114.3C341 89 316.5 32 228.7 32 140.7 32 94 87 94 136.3l73.5 6.8c16.3-49.5 54.2-49.5 54.2-49.5 40.7-.1 35.5 29.8 35.5 69.1zm0 86.8c0 80-84.2 68-84.2 17.2 0-47.2 50.5-56.7 84.2-57.8v40.6zm136 163.5c-7.7 10-70 67-174.5 67S34.2 408.5 9.7 379c-6.8-7.7 1-11.3 5.5-8.3C88.5 415.2 203 488.5 387.7 401c7.5-3.7 13.3 2 5.5 12zm39.8 2.2c-6.5 15.8-16 26.8-21.2 31-5.5 4.5-9.5 2.7-6.5-3.8s19.3-46.5 12.7-55c-6.5-8.3-37-4.3-48-3.2-10.8 1-13 2-14-.3-2.3-5.7 21.7-15.5 37.5-17.5 15.7-1.8 41-.8 46 5.7 3.7 5.1 0 27.1-6.5 43.1z"],"#FF9900"],"amd":["AMD","0 0 24 24",["M18.324 9.137l1.559 1.56h2.556v2.557L24 14.814V9.137zM2 9.52l-2 4.96h1.309l.37-.982H3.9l.408.982h1.338L3.432 9.52zm4.209 0v4.955h1.238v-3.092l1.338 1.562h.188l1.338-1.556v3.091h1.238V9.52H10.47l-1.592 1.845L7.287 9.52zm6.283 0v4.96h2.057c1.979 0 2.88-1.046 2.88-2.472 0-1.36-.937-2.488-2.747-2.488zm1.237.91h.792c1.17 0 1.63.711 1.63 1.57 0 .728-.372 1.572-1.616 1.572h-.806zm-10.985.273l.791 1.932H2.008zm17.137.307l-1.604 1.603v2.25h2.246l1.604-1.607h-2.246z"],"#ED1C24"],"americanexpress":["American Express","0 0 24 24",["M16.015 14.378c0-.32-.135-.496-.344-.622-.21-.12-.464-.135-.81-.135h-1.543v2.82h.675v-1.027h.72c.24 0 .39.024.478.125.12.13.104.38.104.55v.35h.66v-.555c-.002-.25-.017-.376-.108-Line truncated
window.OPENMFA_ICON_ALIASES = {"1passwordcom":"1password","agilebits":"1password","airbnbcom":"airbnb","airtablecom":"airtable","akamailinode":"akamai","amazonaws":"aws","amazoncom":"amazon","amazonprime":"amazon","amazonwebservices":"aws","amdcom":"amd","americanexpresscom":"americanexpress","amex":"americanexpress","anthropiccom":"anthropic","aol":"yahoo","appleid":"apple","asanacom":"asana","atlas":"mongodb","atlassianaccount":"atlassian","auth0com":"auth0","awsiam":"aws","azure":"microsoft","b2":"backblaze","backblazecom":"backblaze","basecampcom":"basecamp","battle":"battledotnet","battlenet":"battledotnet","behancenet":"behance","binancecom":"binance","binanceus":"binance","bitbucketorg":"bitbucket","bitwardencom":"bitwarden","blizzard":"battledotnet","blueskysocial":"bluesky","bofa":"bankofamerica","boxcom":"box","bsky":"bluesky","cfaccess":"cloudflare","chasecom":"chase","ciscocom":"cisco","claudeai":"claude","clickupcom":"clickup","cloudflareaccess":"cloudflare","coinbasecom":"coinbase","coinbasepro":"coinbase","confluence":"atlassian","courseraorg":"coursera","datadoghq":"datadog","discordapp":"discord","discordcom":"discord","do":"digitalocean","dockerhub":"docker","dribbblecom":"dribbble","dropboxcom":"dropbox","dsm":"synology","duolingocom":"duolingo","eaaccount":"ea","ebaycom":"ebay","elasticcloud":"elastic","elasticsearch":"elastic","electronicarts":"ea","entra":"microsoft","epic":"epicgames","epicgamesstore":"epicgames","etsycom":"etsy","evernotecom":"evernote","fb":"facebook","figmacom":"figma","fly":"flydotio","flyio":"flydotio","gcp":"googlecloud","geforce":"nvidia","geforcenow":"nvidia","ghostorg":"ghost","githubcom":"github","gitlabcom":"gitlab","godaddycom":"godaddy","gog":"gogdotcom","gogcom":"gogdotcom","googleaccount":"google","googlecloudplatform":"googlecloud","googleworkspace":"google","grafanacloud":"grafana","gsuite":"google","hass":"homeassistant","hetznercloud":"hetzner","hetzneronline":"hetzner","homeassistantio":"homeassistant","hostingercom":"hostinger","hotmail":"microsoft","hotmailcom":"microsoft","hubspotcom":"hubspot","huggingfaceco":"huggingface","humble":"humblebundle","icloudcom":"icloud","iftttcom":"ifttt","ig":"instagram","intelcom":"intel","jirasoftware":"jira","jpmorganchase":"chase","k8s":"kubernetes","keepersecurity":"keeper","kucoincom":"kucoin","lastpasscom":"lastpass","leagueoflegends":"riotgames","limetech":"unraid","linearapp":"linear","linkedincom":"linkedin","linode":"akamai","live":"microsoft","livecom":"microsoft","mailchimpcom":"mailchimp","mastodonsocial":"mastodon","mecom":"icloud","mediumcom":"medium","megaio":"mega","meganz":"mega","meraki":"cisco","microsoft365":"microsoft","microsoftaccount":"microsoft","microsoftazure":"microsoft","mint":"intuit","mirocom":"miro","mongodbatlas":"mongodb","nabucasa":"homeassistant","namecheapcom":"namecheap","netflixcom":"netflix","netlifycom":"netlify","newreliccom":"newrelic","nextcloudcom":"nextcloud","nord":"nordvpn","nordaccount":"nordvpn","notionso":"notion","npmjs":"npm","npmjscom":"npm","nvidiacom":"nvidia","office":"microsoft","office365":"microsoft","oktacom":"okta","onedrive":"microsoft","onepassword":"1password","openseaio":"opensea","origin":"ea","outlook":"microsoft","outlookcom":"microsoft","ovhcloud":"ovh","pagerdutycom":"pagerduty","patreoncom":"patreon","paypalcom":"paypal","playstationnetwork":"playstation","plextv":"plex","porkbuncom":"porkbun","primevideo":"amazon","protondrive":"proton","protonmailcom":"protonmail","protonme":"proton","protonpass":"proton","psn":"playstation","pypiorg":"pypi","quickbooksonline":"quickbooks","railwayapp":"railway","redditcom":"reddit","renderc":"render","revolutcom":"revolut","riot":"riotgames","robloxcom":"roblox","rockstar":"rockstargames","rubygemsorg":"rubygems","salesforcecom":"salesforce","samsungaccount":"samsung","sentryio":"sentry","sfdc":"salesforce","shopifycom":"shopify","signalorg":"signal","slackcom":"slack","snap":"snapchat","socialclub":"rockstargames","sonyaccount":"sony","sonyplaystation":"playstation","spotifycom":"spotify","squareenixaccount":"squareenix","squarespacecom":"squarespace","squareup":"square","steampowered":"steam","stripecom":"stripe","substackcom":"substack","supabasecom":"supabase","synologyaccount":"synology","synologydsm":"synology","tailscalecom":"tailscale","telegramorg":"telegram","tiktokcom":"tiktok","todoistcom":"todoist","transferwise":"wise","trezorio":"trezor","tumblrcom":"tumblr","turbotax":"intuit","tutaio":"tuta","tutanota":"tuta","tutanotacom":"tuta","twitchtv":"twitch","twitter":"x","twittercom":"x","ubercom":"uber","ubisoftconnect":"ubisoft","udemycom":"udemy","ui":"ubiquiti","unifi":"ubiquiti","unificonsole":"ubiquiti","unraidnet":"unraid","uplay":"ubisoft","valorant":"riotgames","valve":"steam","vaultwarden":"bitwarden","vercelcom":"vercel","vimeocom":"vimeo","vultrcom":"vultr","wellsfargocom":"wellsfargo","whatsappbusiness":"whatsapp","wikimedia":"wikipedia","wikimediafoundation":"wikipedia","wisecom":Line truncated
+1
View File
@@ -27,6 +27,7 @@
<body>
<div id="root"></div>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script src="/assets/icons.js?v={{ASSET_VERSION}}"></script>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body>
</html>
+8
View File
@@ -32,7 +32,15 @@ pub fn logo_svg() -> &'static str {
LOGO_SVG
}
/// The service-icon pack (generated by `scripts/fetch-icons.py`): brand
/// marks for the account list, matched by issuer name in the browser.
#[must_use]
pub fn icons_js() -> &'static str {
ICONS_JS
}
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");
const ICONS_JS: &str = include_str!("icons.js");
+5 -2
View File
@@ -27,8 +27,11 @@
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 web UI lists every account behind its service mark, the way Google or
Microsoft Authenticator does. Codes stay covered until you reveal one, and
Settings -> Log records every reveal and sign-in with who and from where.
(In bridge networking the address is Docker's gateway; use a reverse proxy
or host/br0 networking to see real LAN addresses.)
The distinctive part: each account keeps ONE message in your chosen
channel, rewritten in place every time the code rolls. Three accounts means
+7 -7
View File
@@ -21,11 +21,11 @@ Bump `version` in the workspace `Cargo.toml`, update the `IMAGE_TAG` and
README changelog, and commit. Then tag:
```bash
git tag -a v0.1.1 -m "OpenMFA v0.1.1"
git tag -a v0.2 -m "OpenMFA v0.2"
```
Tags are `vMAJOR.MINOR[.PATCH]` — `v0.1`, `v0.1.1`, … — and the image carries
the same number without the `v` (`0.1`, `0.1.1`) plus a moving `latest`.
Tags are `vMAJOR.MINOR[.PATCH]` — `v0.1`, `v0.2`, … — and the image carries
the same number without the `v` (`0.1`, `0.1.1`, `0.2`) plus a moving `latest`.
## 2. Push the code and the tag
@@ -44,7 +44,7 @@ architecture the builder runs on, so this is fast on Apple Silicon — no QEMU.
attestation manifests BuildKit attaches by default.
```bash
docker build --pull --platform linux/amd64 --provenance=false --sbom=false -f deploy/docker/Dockerfile -t gitea.milesward.dev/mward4/openmfa:0.1.1 -t gitea.milesward.dev/mward4/openmfa:latest .
docker build --pull --platform linux/amd64 --provenance=false --sbom=false -f deploy/docker/Dockerfile -t gitea.milesward.dev/mward4/openmfa:0.2 -t gitea.milesward.dev/mward4/openmfa:latest .
```
## 4. Push the image
@@ -58,7 +58,7 @@ with a bare 401. Write the auth into a throwaway config instead — a config
that already contains an `auths` entry makes the CLI skip the helper:
```bash
export DOCKER_CONFIG="$(mktemp -d)" && printf '{"auths":{"gitea.milesward.dev":{"auth":"%s"}}}' "$(printf 'mward4:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')" > "$DOCKER_CONFIG/config.json" && docker push gitea.milesward.dev/mward4/openmfa:0.1.1 && docker push gitea.milesward.dev/mward4/openmfa:latest; rm -rf "$DOCKER_CONFIG"; unset DOCKER_CONFIG
export DOCKER_CONFIG="$(mktemp -d)" && printf '{"auths":{"gitea.milesward.dev":{"auth":"%s"}}}' "$(printf 'mward4:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')" > "$DOCKER_CONFIG/config.json" && docker push gitea.milesward.dev/mward4/openmfa:0.2 && docker push gitea.milesward.dev/mward4/openmfa:latest; rm -rf "$DOCKER_CONFIG"; unset DOCKER_CONFIG
```
On a Colima host, `export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"`
@@ -78,7 +78,7 @@ The repo is public, so this needs no credentials — which is exactly how Unraid
will fetch it:
```bash
docker pull gitea.milesward.dev/mward4/openmfa:0.1.1
docker pull gitea.milesward.dev/mward4/openmfa:0.2
```
## 6. Deploy on Unraid
@@ -86,7 +86,7 @@ docker pull gitea.milesward.dev/mward4/openmfa:0.1.1
Add a container with Repository set to:
```
gitea.milesward.dev/mward4/openmfa:0.1.1
gitea.milesward.dev/mward4/openmfa:0.2
```
(or `:latest`). Map `/var/lib/openmfa` to `/mnt/user/appdata/openmfa`,
+4 -4
View File
@@ -13,9 +13,9 @@
# GITEA_OWNER default: mward4
# GITEA_REPO default: OpenMFA
# IMAGE_NAME default: openmfa
# IMAGE_TAG default: 0.1.1 (also pushed as :latest)
# IMAGE_TAG default: 0.2 (also pushed as :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64)
# GIT_REF default: v0.1.1 (tag/branch to build)
# GIT_REF default: v0.2 (tag/branch to build)
# WORKDIR default: /tmp/openmfa-build (removed on exit)
#
# After this, set the Unraid template's Repository to:
@@ -27,9 +27,9 @@ 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.1}
IMAGE_TAG=${IMAGE_TAG:-0.2}
PLATFORM=${PLATFORM:-linux/amd64}
GIT_REF=${GIT_REF:-v0.1.1}
GIT_REF=${GIT_REF:-v0.2}
WORKDIR=${WORKDIR:-/tmp/openmfa-build}
# OCI distribution rejects uppercase repository paths, so the image name is
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Regenerate crates/webui/src/icons.js — the bundled service-icon pack.
The web UI is fully offline, so the icons are fetched here, once, and
committed as a generated JavaScript file that the binary embeds. Two sources:
* Simple Icons (CC0-1.0) — the bulk of the pack, with brand colours
* Font Awesome Free brands (CC BY 4.0) — a handful of brands that Simple
Icons no longer carries (Amazon, AWS, Microsoft, LinkedIn, Slack, ...)
Brands remain trademarks of their owners; the pack exists so the account list
can show a recognisable mark instead of an initial. Run with no arguments:
python3 scripts/fetch-icons.py
"""
import json
import re
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
SI_VERSION = "16.30.0"
FA_VERSION = "6.7.2"
SI_CDN = f"https://cdn.jsdelivr.net/npm/simple-icons@{SI_VERSION}"
FA_CDN = f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@{FA_VERSION}/svgs/brands"
OUT = Path(__file__).resolve().parent.parent / "crates/webui/src/icons.js"
# Simple Icons slugs. Roughly the services people actually keep in an
# authenticator, weighted towards the homelab / developer crowd.
SIMPLE = """
github google apple facebook meta instagram x discord telegram dropbox box
cloudflare digitalocean netlify vercel gitlab bitbucket atlassian jira trello
notion evernote zoom twitch reddit pinterest snapchat tiktok steam epicgames
playstation ubisoft ea battledotnet riotgames paypal stripe coinbase binance
robinhood venmo wise revolut shopify wordpress squarespace wix godaddy
namecheap porkbun mailchimp hubspot zendesk okta auth0 1password bitwarden
lastpass dashlane keeper nordvpn protonmail proton protonvpn tuta zoho mega
backblaze synology unraid plex nextcloud homeassistant tailscale wireguard
anthropic claude huggingface docker npm pypi rubygems kubernetes akamai vultr
hetzner ovh figma spotify netflix uber airbnb ebay etsy patreon kickstarter
substack medium tumblr mastodon bluesky threads whatsapp signal wikipedia
ubiquiti nvidia intel amd samsung sony cisco mailgun icloud gmail yubico
duolingo coursera udemy ghost hostinger render railway flydotio supabase
firebase mongodb redis elastic datadog newrelic sentry grafana pagerduty
zapier ifttt airtable asana clickup basecamp linear todoist miro dribbble
behance vimeo youtube cashapp americanexpress kucoin trezor opensea
humblebundle rockstargames squareenix roblox gogdotcom googledrive
googlecloud googleplay intuit quickbooks xero square chase bankofamerica
wellsfargo
""".split()
# Font Awesome brand icons filling Simple Icons' gaps: (fa name, title, colour).
FONT_AWESOME = [
("amazon", "Amazon", "#FF9900"),
("aws", "AWS", "#FF9900"),
("microsoft", "Microsoft", "#737373"),
("linkedin", "LinkedIn", "#0A66C2"),
("slack", "Slack", "#4A154B"),
("xbox", "Xbox", "#107C10"),
("salesforce", "Salesforce", "#00A1E0"),
("yahoo", "Yahoo", "#6001D2"),
]
# Extra ways people spell an issuer → slug. Keys are matched after
# normalisation (lower-case, non-alphanumerics dropped), so "Battle.net",
# "battle net" and "BattleNet" all collapse to "battlenet".
ALIASES = {
# Google
"gmail": "gmail", "googleworkspace": "google", "googleaccount": "google",
"gsuite": "google", "googlecloudplatform": "googlecloud", "gcp": "googlecloud",
# Microsoft
"microsoft365": "microsoft", "office365": "microsoft", "office": "microsoft",
"outlook": "microsoft", "outlookcom": "microsoft", "hotmail": "microsoft",
"hotmailcom": "microsoft", "live": "microsoft", "livecom": "microsoft",
"azure": "microsoft", "microsoftazure": "microsoft", "entra": "microsoft",
"onedrive": "microsoft", "xboxlive": "xbox", "microsoftaccount": "microsoft",
# Amazon
"amazonwebservices": "aws", "amazoncom": "amazon", "amazonaws": "aws",
"awsiam": "aws", "amazonprime": "amazon", "primevideo": "amazon",
# Apple
"appleid": "apple", "icloudcom": "icloud", "mecom": "icloud",
# Meta
"fb": "facebook", "ig": "instagram", "twitter": "x", "xcom": "x",
"xtwitter": "x", "twittercom": "x", "whatsappbusiness": "whatsapp",
# Gaming
"blizzard": "battledotnet", "battlenet": "battledotnet", "battle": "battledotnet",
"epic": "epicgames", "epicgamesstore": "epicgames", "riot": "riotgames",
"leagueoflegends": "riotgames", "valorant": "riotgames", "electronicarts": "ea",
"eaaccount": "ea", "origin": "ea", "psn": "playstation", "playstationnetwork": "playstation",
"sonyplaystation": "playstation", "valve": "steam", "steampowered": "steam",
"gog": "gogdotcom", "gogcom": "gogdotcom", "rockstar": "rockstargames",
"socialclub": "rockstargames", "humble": "humblebundle", "ubisoftconnect": "ubisoft",
"uplay": "ubisoft",
# Dev / infra
"githubcom": "github", "gitlabcom": "gitlab", "dockerhub": "docker",
"npmjs": "npm", "npmjscom": "npm", "pypiorg": "pypi", "rubygemsorg": "rubygems",
"k8s": "kubernetes", "do": "digitalocean", "digitalocean": "digitalocean",
"flyio": "flydotio", "fly": "flydotio", "ovhcloud": "ovh", "hetznercloud": "hetzner",
"hetzneronline": "hetzner", "linode": "akamai", "akamailinode": "akamai",
"cloudflareaccess": "cloudflare", "cfaccess": "cloudflare", "huggingfaceco": "huggingface",
"supabasecom": "supabase", "mongodbatlas": "mongodb", "atlas": "mongodb",
"elasticcloud": "elastic", "elasticsearch": "elastic", "grafanacloud": "grafana",
"sentryio": "sentry", "confluence": "atlassian", "atlassianaccount": "atlassian",
"jirasoftware": "jira", "bitbucketorg": "bitbucket", "pagerdutycom": "pagerduty",
"wpcom": "wordpress", "wordpresscom": "wordpress", "wordpressorg": "wordpress",
"namecheapcom": "namecheap", "godaddycom": "godaddy", "porkbuncom": "porkbun",
"netlifycom": "netlify", "vercelcom": "vercel", "renderc": "render", "railwayapp": "railway",
# Homelab
"hass": "homeassistant", "homeassistantio": "homeassistant", "nabucasa": "homeassistant",
"unifi": "ubiquiti", "ui": "ubiquiti", "unificonsole": "ubiquiti", "plextv": "plex",
"synologyaccount": "synology", "synologydsm": "synology", "dsm": "synology",
"nextcloudcom": "nextcloud", "unraidnet": "unraid", "limetech": "unraid",
"tailscalecom": "tailscale",
# Security
"protonme": "proton", "protonmailcom": "protonmail", "protonmail": "protonmail",
"protonpass": "proton", "protondrive": "proton", "tutanota": "tuta", "tutanotacom": "tuta",
"tutaio": "tuta", "1passwordcom": "1password", "onepassword": "1password",
"agilebits": "1password", "bitwardencom": "bitwarden", "vaultwarden": "bitwarden",
"lastpasscom": "lastpass", "keepersecurity": "keeper", "nord": "nordvpn",
"nordaccount": "nordvpn", "yubikey": "yubico", "oktacom": "okta", "auth0com": "auth0",
# Money
"coinbasecom": "coinbase", "coinbasepro": "coinbase", "binancecom": "binance",
"binanceus": "binance", "paypalcom": "paypal", "stripecom": "stripe",
"transferwise": "wise", "wisecom": "wise", "revolutcom": "revolut",
"amex": "americanexpress", "americanexpresscom": "americanexpress",
"jpmorganchase": "chase", "chasecom": "chase", "bofa": "bankofamerica",
"wellsfargocom": "wellsfargo", "cashapp": "cashapp", "squareup": "square",
"quickbooksonline": "quickbooks", "turbotax": "intuit", "mint": "intuit",
# Comms / social
"slackcom": "slack", "discordcom": "discord", "discordapp": "discord",
"zoomus": "zoom", "telegramorg": "telegram", "linkedincom": "linkedin",
"redditcom": "reddit", "twitchtv": "twitch", "tiktokcom": "tiktok", "snap": "snapchat",
"bsky": "bluesky", "blueskysocial": "bluesky", "mastodonsocial": "mastodon",
"signalorg": "signal", "wikimedia": "wikipedia", "wikimediafoundation": "wikipedia",
# Mail hosts (matched from the label's domain when the issuer is unknown)
"yahoocom": "yahoo", "ymail": "yahoo", "ymailcom": "yahoo", "aol": "yahoo",
"zohomail": "zoho", "zohocom": "zoho", "megaio": "mega", "meganz": "mega",
"backblazecom": "backblaze", "b2": "backblaze",
# Misc
"dropboxcom": "dropbox", "boxcom": "box", "notionso": "notion", "evernotecom": "evernote",
"figmacom": "figma", "spotifycom": "spotify", "netflixcom": "netflix",
"ubercom": "uber", "airbnbcom": "airbnb", "ebaycom": "ebay", "etsycom": "etsy",
"patreoncom": "patreon", "substackcom": "substack", "mediumcom": "medium",
"tumblrcom": "tumblr", "shopifycom": "shopify", "squarespacecom": "squarespace",
"wixcom": "wix", "mailchimpcom": "mailchimp", "hubspotcom": "hubspot",
"zendeskcom": "zendesk", "duolingocom": "duolingo", "courseraorg": "coursera",
"udemycom": "udemy", "ghostorg": "ghost", "hostingercom": "hostinger",
"airtablecom": "airtable", "asanacom": "asana", "clickupcom": "clickup",
"basecampcom": "basecamp", "linearapp": "linear", "todoistcom": "todoist",
"mirocom": "miro", "dribbblecom": "dribbble", "behancenet": "behance",
"vimeocom": "vimeo", "youtubecom": "youtube", "yt": "youtube",
"openseaio": "opensea", "trezorio": "trezor", "kucoincom": "kucoin",
"salesforcecom": "salesforce", "sfdc": "salesforce", "nvidiacom": "nvidia",
"geforce": "nvidia", "geforcenow": "nvidia", "samsungaccount": "samsung",
"sonyaccount": "sony", "ciscocom": "cisco", "meraki": "cisco", "intelcom": "intel",
"amdcom": "amd",
"squareenixaccount": "squareenix", "robloxcom": "roblox", "zapiercom": "zapier",
"iftttcom": "ifttt", "vultrcom": "vultr", "datadoghq": "datadog", "newreliccom": "newrelic",
"clickup": "clickup", "anthropiccom": "anthropic", "claudeai": "claude",
}
def norm(s: str) -> str:
return re.sub(r"[^a-z0-9]+", "", s.lower().replace("&", "and"))
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "openmfa-fetch-icons/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8")
def parse_svg(svg: str):
vb = re.search(r'viewBox="([^"]+)"', svg)
paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
if not vb or not paths:
raise ValueError("no viewBox/path")
return vb.group(1), paths
def main() -> int:
index = json.loads(fetch(f"{SI_CDN}/data/simple-icons.json"))
by_slug = {i["slug"]: i for i in (index["icons"] if isinstance(index, dict) else index)}
missing = [s for s in SIMPLE if s not in by_slug]
if missing:
print("not in simple-icons:", " ".join(missing), file=sys.stderr)
return 1
jobs = [(s, "si") for s in SIMPLE] + [(name, "fa") for name, _, _ in FONT_AWESOME]
def load(job):
name, src = job
url = f"{SI_CDN}/icons/{name}.svg" if src == "si" else f"{FA_CDN}/{name}.svg"
return job, parse_svg(fetch(url))
icons = {}
aliases = {}
with ThreadPoolExecutor(max_workers=12) as pool:
for (name, src), (vb, paths) in pool.map(load, jobs):
if src == "si":
meta = by_slug[name]
title, hexcol = meta["title"], "#" + meta["hex"]
for aka in (meta.get("aliases") or {}).get("aka", []):
aliases.setdefault(norm(aka), name)
else:
title, hexcol = next((t, c) for n, t, c in FONT_AWESOME if n == name)
icons[name] = [title, vb, paths, hexcol]
aliases.setdefault(norm(title), name)
for k, v in ALIASES.items():
if v not in icons:
print(f"alias {k!r} points at unknown icon {v!r}", file=sys.stderr)
return 1
aliases[norm(k)] = v
# A key that is itself an icon needs no alias entry.
aliases = {k: v for k, v in sorted(aliases.items()) if k not in icons and k}
body = (
"// GENERATED by scripts/fetch-icons.py — do not edit by hand.\n"
f"// Simple Icons {SI_VERSION} (CC0-1.0) and Font Awesome Free {FA_VERSION} brands\n"
"// (CC BY 4.0). Brand marks are trademarks of their respective owners.\n"
"// Shape: slug -> [title, viewBox, [path d, ...], brand colour].\n"
"window.OPENMFA_ICONS = "
+ json.dumps(dict(sorted(icons.items())), separators=(",", ":"), ensure_ascii=False)
+ ";\n"
"window.OPENMFA_ICON_ALIASES = "
+ json.dumps(aliases, separators=(",", ":"), ensure_ascii=False)
+ ";\n"
)
OUT.write_text(body)
print(f"wrote {OUT} — {len(icons)} icons, {len(aliases)} aliases, {len(body)//1024} KiB")
return 0
if __name__ == "__main__":
sys.exit(main())