Skip to content

Commit 70a401f

Browse files
dahliaclaude
andcommitted
Add emoji-based OTP authentication example
Adds a new example demonstrating how to implement one-time passcode authentication using BotKit's poll functionality. The example creates an emoji-based 2FA system where users authenticate by voting for matching emojis in a direct message poll. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 703d52b commit 70a401f

File tree

3 files changed

+286
-1
lines changed

3 files changed

+286
-1
lines changed

CHANGES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ To be released.
99
- BotKit now supports Node.js alongside of Deno. The minimum required
1010
version of Node.js is 22.0.0.
1111

12-
- BotKit now supports publishing polls. [[#7]]
12+
- BotKit now supports publishing polls. [[#7], [#8]]
1313

1414
- Added `Poll` interface.
1515
- Added `Vote` interface.
@@ -35,6 +35,7 @@ To be released.
3535
- Upgraded Fedify to 1.7.1.
3636

3737
[#7]: https://github.com/fedify-dev/botkit/issues/7
38+
[#8]: https://github.com/fedify-dev/botkit/pull/8
3839

3940

4041
Version 0.2.0

docs/examples.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,59 @@ BotKit. The bot performs the following actions:
2525
:::
2626

2727

28+
One-time passcode authentication bot
29+
------------------------------------
30+
31+
This example demonstrates how to implement an emoji-based one-time passcode
32+
authentication system using BotKit's poll functionality. The bot provides
33+
a simple two-factor authentication mechanism through the fediverse.
34+
35+
The authentication flow works as follows:
36+
37+
1. *Initial setup*: The user visits the web interface and enters their fediverse
38+
handle (e.g., `@username@server.com`).
39+
40+
2. *Challenge generation*: The system generates a random set of emojis and sends
41+
a direct message containing a poll with all available emoji options to
42+
the user's fediverse account.
43+
44+
3. *Web interface display*: The correct emoji sequence is displayed on the
45+
web page.
46+
47+
4. *User response*: The user votes for the matching emojis in the poll they
48+
received via direct message.
49+
50+
5. *Verification*: The system verifies that the user selected exactly
51+
the same emojis shown on the web page.
52+
53+
6. *Authentication result*: If the emoji selection matches, authentication is
54+
successful.
55+
56+
Key features:
57+
58+
- Uses BotKit's [poll functionality](./concepts/message.md#polls) for secure
59+
voting
60+
- Implements a 15-minute expiration for both the challenge and authentication
61+
attempts
62+
- Provides a clean web interface using [Hono] framework and [Pico CSS]
63+
- Stores temporary data using [Deno KV] for session management
64+
- Supports both direct message delivery and real-time vote tracking
65+
66+
This example showcases how to combine ActivityPub's social features with web
67+
authentication, demonstrating BotKit's capability to bridge fediverse
68+
interactions with traditional web applications.
69+
70+
::: code-group
71+
72+
<<< @/../examples/otp.tsx [otp.tsx]
73+
74+
:::
75+
76+
[Hono]: https://hono.dev/
77+
[Pico CSS]: https://picocss.com/
78+
[Deno KV]: https://deno.com/kv
79+
80+
2881
FediChatBot
2982
-----------
3083

examples/otp.tsx

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/** @jsx react-jsx */
2+
/** @jsxImportSource hono/jsx */
3+
import { createBot, isActor, Question, text } from "@fedify/botkit";
4+
import { DenoKvMessageQueue, DenoKvStore } from "@fedify/fedify/x/denokv";
5+
import { Hono } from "hono";
6+
import type { FC } from "hono/jsx";
7+
import { getXForwardedRequest } from "x-forwarded-fetch";
8+
9+
const kv = await Deno.openKv();
10+
11+
const bot = createBot<void>({
12+
username: "otp",
13+
name: "OTP Bot",
14+
summary:
15+
text`This bot provides a simple one-time passcode authentication using emojis.`,
16+
icon: new URL("https://botkit.fedify.dev/favicon-192x192.png"),
17+
kv: new DenoKvStore(kv),
18+
queue: new DenoKvMessageQueue(kv),
19+
});
20+
21+
bot.onVote = async (_session, vote) => {
22+
const recipient = await kv.get<string>(["recipients", vote.message.id.href]);
23+
if (recipient?.value !== vote.actor.id?.href) return;
24+
await kv.set(["votes", vote.message.id.href, vote.option], vote.option, {
25+
expireIn: 15 * 60 * 1000, // 15 minutes
26+
});
27+
};
28+
29+
const EMOJI_CODES = [
30+
"🌈",
31+
"🌟",
32+
"🌸",
33+
"🍀",
34+
"🍉",
35+
"🍦",
36+
"🍿",
37+
"🎈",
38+
"🎉",
39+
"🎨",
40+
"🐢",
41+
"🐬",
42+
"👻",
43+
"👾",
44+
"💎",
45+
"🔥",
46+
];
47+
48+
function generateRandomEmojis(): readonly string[] {
49+
// Generate a random 16-bit number (except for zero):
50+
const randomBytes = new Uint8Array(2);
51+
while (true) {
52+
crypto.getRandomValues(randomBytes);
53+
// Regenerate if the number is zero:
54+
if (randomBytes[0] !== 0 || randomBytes[1] !== 0) break;
55+
}
56+
// Turn the 16-bit number into 16 emojis, e.g.,
57+
// 1000_1000_1001_0000 becomes ["🌟","🍉", "🎉", "🐬"]:
58+
const emojis: string[] = [];
59+
for (let i = 0; i < 16; i++) {
60+
// Get the i-th bit from the random number:
61+
const bit = (randomBytes[i >> 3] >> (7 - (i & 0b111))) & 1;
62+
// If the bit is 1, add the corresponding emoji to the array:
63+
if (bit === 1) emojis.push(EMOJI_CODES[i]);
64+
}
65+
return emojis;
66+
}
67+
68+
const Layout: FC = (props) => {
69+
return (
70+
<html>
71+
<head>
72+
<meta charset="utf-8" />
73+
<title>OTP bot</title>
74+
<link
75+
rel="stylesheet"
76+
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.lime.min.css"
77+
/>
78+
</head>
79+
<body>
80+
<main class="container">
81+
{props.children}
82+
</main>
83+
</body>
84+
</html>
85+
);
86+
};
87+
88+
const Form: FC = () => {
89+
return (
90+
<Layout>
91+
<hgroup>
92+
<h1>OTP Demo using BotKit</h1>
93+
<p>
94+
This demo shows how to create a simple emoji-based one-time passcode
95+
authentication using <a href="https://botkit.fedify.dev/">BotKit</a>.
96+
</p>
97+
</hgroup>
98+
<form action="/otp" method="post">
99+
<fieldset>
100+
<label>
101+
Your fediverse handle
102+
<input
103+
name="handle"
104+
type="text"
105+
placeholder="@username@server.com"
106+
required
107+
inputmode="email"
108+
pattern="^@[^@]+@[^@]+$"
109+
/>
110+
</label>
111+
</fieldset>
112+
<input type="submit" value="Authenticate" />
113+
</form>
114+
</Layout>
115+
);
116+
};
117+
118+
const EmojiCode: FC<
119+
{ handle: string; emojis: readonly string[]; messageId: URL }
120+
> = (
121+
props,
122+
) => {
123+
return (
124+
<Layout>
125+
<hgroup>
126+
<h1>A direct message has been sent</h1>
127+
<p>
128+
A direct message has been sent to{" "}
129+
<strong>{props.handle}</strong>. Please choose the emojis below to
130+
authenticate:
131+
</p>
132+
</hgroup>
133+
<ul style="padding: 0; display: flex; justify-content: center; gap: 1em; margin-top: 2em; margin-bottom: 2em;">
134+
{props.emojis.map((emoji) => (
135+
<li key={emoji} style="list-style: none; font-size: 3em;">{emoji}</li>
136+
))}
137+
</ul>
138+
<form action="/authenticate" method="post">
139+
<input
140+
type="hidden"
141+
name="messageId"
142+
value={props.messageId.href}
143+
/>
144+
<input type="submit" value="I chose the emojis above" />
145+
</form>
146+
</Layout>
147+
);
148+
};
149+
150+
const Result: FC<{ authenticated: boolean }> = (props) => {
151+
return (
152+
<Layout>
153+
<hgroup>
154+
<h1>
155+
{props.authenticated ? "Authenticated" : "Authentication failed"}
156+
</h1>
157+
{props.authenticated
158+
? <p>You have successfully authenticated!</p>
159+
: <p>Authentication failed. Please try again.</p>}
160+
</hgroup>
161+
</Layout>
162+
);
163+
};
164+
165+
const app = new Hono();
166+
167+
app.get("/", (c) => {
168+
return c.html(<Form />);
169+
});
170+
171+
app.post("/otp", async (c) => {
172+
const form = await c.req.formData();
173+
const handle = form.get("handle")?.toString();
174+
if (handle == null) return c.notFound();
175+
const emojis = generateRandomEmojis();
176+
const session = bot.getSession(c.req.url);
177+
const recipient = await session.context.lookupObject(handle);
178+
if (!isActor(recipient)) return c.notFound();
179+
const message = await session.publish(
180+
text`${recipient} Please choose the only emojis you see in the web page to authenticate:`,
181+
{
182+
visibility: "direct",
183+
class: Question,
184+
poll: {
185+
multiple: true,
186+
options: EMOJI_CODES,
187+
endTime: Temporal.Now.instant().add({ minutes: 15 }),
188+
},
189+
},
190+
);
191+
await kv.set(["emojis", message.id.href], emojis, {
192+
expireIn: 15 * 60 * 1000, // 15 minutes
193+
});
194+
await kv.set(["recipients", message.id.href], recipient.id?.href, {
195+
expireIn: 15 * 60 * 1000, // 15 minutes
196+
});
197+
return c.html(
198+
<EmojiCode handle={handle} emojis={emojis} messageId={message.id} />,
199+
);
200+
});
201+
202+
app.post("/authenticate", async (c) => {
203+
const form = await c.req.formData();
204+
const messageId = form.get("messageId")?.toString();
205+
if (messageId == null) return c.notFound();
206+
const key = await kv.get<string>(["emojis", messageId]);
207+
if (key?.value == null) return c.notFound();
208+
const emojis = new Set(key.value);
209+
const answer = new Set<string>();
210+
for await (const entry of kv.list({ prefix: ["votes", messageId] })) {
211+
if (entry.key.length < 3 || typeof entry.key[2] !== "string") continue;
212+
answer.add(entry.key[2]);
213+
}
214+
const authenticated = answer.size === emojis.size &&
215+
answer.difference(emojis).size === 0;
216+
return c.html(<Result authenticated={authenticated} />);
217+
});
218+
219+
export default {
220+
async fetch(request: Request): Promise<Response> {
221+
request = await getXForwardedRequest(request);
222+
const url = new URL(request.url);
223+
if (
224+
url.pathname.startsWith("/.well-known/") ||
225+
url.pathname.startsWith("/ap/")
226+
) {
227+
return await bot.fetch(request);
228+
}
229+
return await app.fetch(request);
230+
},
231+
};

0 commit comments

Comments
 (0)