Platform-neutral successor to Discord Authenticator, forked from its v0.1.2
state with fresh history. Same vault, same UI, same live-message mechanism —
now behind one `Sink` that speaks three platforms.
Why each platform needs what it needs (this drove the whole settings model):
- Discord: an incoming-webhook URL. Webhooks can edit their own messages.
- Slack: a bot token (xoxb-, scope chat:write) + channel ID. Slack incoming
webhooks can post but never edit, and editing IS the live mechanism, so
chat.postMessage / chat.update via a bot is the only path.
- Telegram: a bot token + chat id. No webhook concept exists. The app detects
the chat id itself from the bot's pending updates ("send /start, then
Detect"), since users rarely know it.
- core: Provider enum; settings hold all three configs so switching back and
forth never re-asks for tokens; per-platform shape validation that names the
common mistakes (xoxp- user token, #name instead of channel ID); a
destination key that clears message ids on platform/channel change but not
on token rotation
- delivery: shared DeliveryError + pacing Governor; Discord client refactored
onto them; new Slack client (classifies Slack's ok:false-on-HTTP-200
errors) and Telegram client ("message is not modified" is success; chat
detection via getUpdates); provider-agnostic message builders — code in a
copyable span everywhere, rollover time as live as each platform allows
- http-api: setup accepts an optional provider; settings PUT/GET carry the
full model; /api/slack/whoami, /api/telegram/whoami, /api/telegram/chats
- webui: three-way optional picker on setup and in Settings → Delivery with
guided per-platform fields; "Check token" (Slack) and "Detect chat"
(Telegram); inline white mark via currentColor
- brand: white mark on the dark plate; blue stays with the Discord sibling
- docs/TEAMS.md: exploration for v0.2 — Teams connectors were disabled May
2026 and Workflows webhooks can't edit, so live tiles need a Bot Framework
bot; options for the inbound endpoint on a homelab and a recommended scope
Verified against mock Discord, Slack and Telegram servers speaking each
platform's real protocol shape: edits target the same message rather than
re-posting, deleted tiles are recovered, dead credentials are surfaced with a
next step, renames refresh without a rollover, and switching platform reposts
on the new one and leaves the old one alone. 138 tests.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
84 lines
2.5 KiB
Rust
84 lines
2.5 KiB
Rust
//! Emit a Google Authenticator export QR for manual testing:
|
|
//! `cargo run -p openmfa-http-api --example make-google-export -- out.png 8`
|
|
fn put_varint(v: u64, out: &mut Vec<u8>) {
|
|
let mut v = v;
|
|
loop {
|
|
let mut b = (v & 0x7f) as u8;
|
|
v >>= 7;
|
|
if v != 0 {
|
|
b |= 0x80;
|
|
}
|
|
out.push(b);
|
|
if v == 0 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
|
|
put_varint((u64::from(field) << 3) | 2, out);
|
|
put_varint(bytes.len() as u64, out);
|
|
out.extend_from_slice(bytes);
|
|
}
|
|
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
|
|
put_varint(u64::from(field) << 3, out);
|
|
put_varint(v, out);
|
|
}
|
|
|
|
fn main() {
|
|
let mut a = std::env::args().skip(1);
|
|
let out = a
|
|
.next()
|
|
.expect("usage: make-google-export <out.png> [count]");
|
|
let n: usize = a.next().unwrap_or_else(|| "6".into()).parse().unwrap();
|
|
|
|
let names = [
|
|
"Cloudflare",
|
|
"Plex",
|
|
"Ubisoft",
|
|
"GitHub",
|
|
"Steam",
|
|
"Proton",
|
|
"Fastmail",
|
|
"Namecheap",
|
|
"Vercel",
|
|
"Backblaze",
|
|
];
|
|
let mut payload = Vec::new();
|
|
for i in 0..n {
|
|
let mut e = Vec::new();
|
|
put_bytes(1, &[0x41 + i as u8; 20], &mut e);
|
|
put_bytes(2, b"[email protected]", &mut e);
|
|
put_bytes(3, names[i % names.len()].as_bytes(), &mut e);
|
|
put_uint(4, 1, &mut e);
|
|
// Give one entry 8 digits so the UI's variant chips get exercised.
|
|
put_uint(5, if i == 2 { 2 } else { 1 }, &mut e);
|
|
// And make one HOTP so the "skipped" path shows up.
|
|
put_uint(6, if i == 4 { 1 } else { 2 }, &mut e);
|
|
put_bytes(1, &e, &mut payload);
|
|
}
|
|
put_uint(2, 1, &mut payload);
|
|
put_uint(3, 2, &mut payload); // batch_size
|
|
put_uint(4, 0, &mut payload); // batch_index
|
|
put_uint(5, 77, &mut payload);
|
|
|
|
use base64::Engine;
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
|
let escaped = b64
|
|
.replace('+', "%2B")
|
|
.replace('/', "%2F")
|
|
.replace('=', "%3D");
|
|
let uri = format!("otpauth-migration://offline?data={escaped}");
|
|
|
|
let code = qrcode::QrCode::new(uri.as_bytes()).unwrap();
|
|
let img = code
|
|
.render::<image::Luma<u8>>()
|
|
.module_dimensions(6, 6)
|
|
.quiet_zone(true)
|
|
.build();
|
|
image::DynamicImage::ImageLuma8(img).save(&out).unwrap();
|
|
println!(
|
|
"wrote {out} ({n} entries, {} bytes of payload)",
|
|
payload.len()
|
|
);
|
|
}
|