Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
e8c77aa
Create spike.ts
lirbank Sep 26, 2026
130583b
Update spike.ts
lirbank Sep 26, 2026
f2764fd
Add users table to spike
lirbank Sep 26, 2026
cfbbc56
Create spike-no-kernel.ts
lirbank Sep 26, 2026
1a8641d
Create tsconfig.json
lirbank Sep 26, 2026
2ff8e87
Refactor
lirbank Sep 26, 2026
3d2b582
Update tsconfig.json
lirbank Sep 26, 2026
6793ac2
Update spike-no-kernel.ts
lirbank Sep 27, 2026
2b3b0a6
Update spike-no-kernel.ts
lirbank Sep 27, 2026
bceb70b
Update spike-no-kernel.ts
lirbank Sep 27, 2026
a44285e
Add factories to spike
lirbank Sep 27, 2026
6530840
Add fake passkeys to spike
lirbank Sep 27, 2026
734e1fd
Return failure reasons from verify
lirbank Sep 27, 2026
a43fbd9
Shape passkey factory like WebAuthn
lirbank Sep 27, 2026
c5733fd
Add user handle to passkeys
lirbank Sep 27, 2026
6700d16
Update spike-no-kernel.ts
lirbank Sep 27, 2026
389605a
Move spike into package folder
lirbank Sep 27, 2026
c75b70d
Add spike package files
lirbank Sep 27, 2026
31f54d8
Split spike into modules
lirbank Sep 27, 2026
94eb2d2
Add signed session and contract
lirbank Sep 27, 2026
321acf7
Group sessions and strategies
lirbank Sep 27, 2026
429bb92
Add spike decisions
lirbank Sep 27, 2026
7b0e8b9
Pin Bun and freeze lockfile
lirbank Sep 27, 2026
b74d46d
Library generates client strings
lirbank Sep 27, 2026
d3d4686
Close the library to extension
lirbank Sep 27, 2026
6c6c1bd
Return results like safeParse
lirbank Sep 27, 2026
8419203
Rename
lirbank Sep 27, 2026
4f30c00
Rename sessions to session managers
lirbank Sep 27, 2026
e8b47ab
Rename
lirbank Sep 27, 2026
fcc5f93
Make the spike OTP real
lirbank Sep 27, 2026
8851a2e
Simplify session managers, add expiry
lirbank Sep 27, 2026
259908d
Add session end to spike
lirbank Sep 27, 2026
6637a70
Rewire OTP example to spike
lirbank Sep 27, 2026
7e1f7f8
Remove authax from OTP example
lirbank Sep 27, 2026
beb4c28
Move table helper to demo
lirbank Sep 27, 2026
fac4e3b
Reshape memory table helper
lirbank Sep 27, 2026
f48af74
Add update and findOrInsert
lirbank Sep 28, 2026
b603e27
Use memory table in example
lirbank Sep 28, 2026
d6b23f8
Copy WebAuthn code into spike
lirbank Sep 28, 2026
1ecfefe
Make the spike passkey real
lirbank Sep 28, 2026
6a4d184
Rewire passkey example to spike
lirbank Sep 28, 2026
8e226df
Move button cursor to shared styles
lirbank Sep 28, 2026
3cfaacd
Log why a passkey failed
lirbank Sep 28, 2026
576771f
Let a proof work twice
lirbank Oct 2, 2026
00fb4aa
Split OTP instances by purpose
lirbank Oct 2, 2026
8860174
Take the proof out
lirbank Oct 2, 2026
b39b159
Delete spike.ts
lirbank Oct 2, 2026
3cc9877
Add delete account with OTP
lirbank Oct 2, 2026
8ecf86c
Rewire passkey-otp example to spike
lirbank Oct 2, 2026
f0814b0
Add analytics fake to examples
lirbank Oct 2, 2026
4405245
Add delete account with passkey
lirbank Oct 2, 2026
3b59ac9
Name the ceremony in hook messages
lirbank Oct 2, 2026
e793321
Simplify the demo databases
lirbank Oct 2, 2026
cdb0b04
Write out each OTP instance
lirbank Oct 3, 2026
e15263b
Move otp-memory to kitchen-sink
lirbank Oct 3, 2026
da0fda8
Point references at kitchen-sink
lirbank Oct 3, 2026
c530ec4
Rename the kitchen sink's names
lirbank Oct 3, 2026
8221cc7
Deps
lirbank Oct 3, 2026
b824446
Sign in by OTP over REST
lirbank Oct 3, 2026
81b02ce
Move handlers to flows
lirbank Oct 4, 2026
24b4c87
Add the OTP auth flow
lirbank Oct 4, 2026
e002d47
Drop the delete account reconfirm
lirbank Oct 4, 2026
34c8ecf
Add a plain Bun OTP example
lirbank Oct 4, 2026
8c2b99b
Use seconds for all times
lirbank Oct 4, 2026
02c7dab
Check Origin against configured origins
lirbank Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.2
- run: bun install --frozen-lockfile
- run: bun run format:check

Expand All @@ -24,8 +22,6 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.2
- run: bun install --frozen-lockfile
- run: bun run lint:check

Expand All @@ -34,8 +30,6 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.2
- run: bun install --frozen-lockfile
- run: bun run test:run

Expand All @@ -44,7 +38,5 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.2
- run: bun install --frozen-lockfile
- run: bun run typecheck
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ Example names combine the authentication flow and storage, with a transport suff

| Flow | TanStack Start | Next.js | Bun | Convex |
| --- | --- | --- | --- | --- |
| `otp` | [memory](examples/tanstack-start-react/otp-memory), postgres (planned) | [memory](examples/nextjs/otp-memory) | [memory-cookie](examples/bun-react/otp-memory-cookie), [memory-header](examples/bun-react/otp-memory-header) | [Convex](examples/convex-react/otp) |
| `otp` | postgres (planned) | [memory](examples/nextjs/otp-memory) | [memory-cookie](examples/bun-react/otp-memory-cookie), [memory-header](examples/bun-react/otp-memory-header) | [Convex](examples/convex-react/otp) |
| `passkey` | [memory](examples/tanstack-start-react/passkey-memory), postgres (planned) | — | — | — |
| `otp-passkey` | [memory](examples/tanstack-start-react/otp-passkey-memory), postgres (planned) | — | — | — |
| `otp-passkey-strict` | [memory](examples/tanstack-start-react/otp-passkey-strict-memory), postgres (planned) | — | — | — |
| `passkey-otp` | [memory](examples/tanstack-start-react/passkey-otp-memory), postgres (planned) | — | — | — |
| `kitchen-sink` | [memory](examples/tanstack-start-react/kitchen-sink) | — | — | — |

Memory storage is for demos. Its state is local to each server instance and is lost on restart.

Expand Down
293 changes: 154 additions & 139 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 0 additions & 8 deletions examples/bun-react/otp-memory-cookie/src/index.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,2 @@
@import "tailwindcss";
@import "@repo/shared-react/styles.css";

/* Button cursor */
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
8 changes: 0 additions & 8 deletions examples/bun-react/otp-memory-header/src/index.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,2 @@
@import "tailwindcss";
@import "@repo/shared-react/styles.css";

/* Button cursor */
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# otp-memory
# otp

## License

Expand Down
17 changes: 17 additions & 0 deletions examples/bun/otp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@repo/example-bun-spike-otp",
"private": true,
"license": "MIT-0",
"type": "module",
"scripts": {
"dev": "bun --hot src/server.ts",
"typecheck": "tsc"
},
"dependencies": {
"@repo/spike": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.4.2",
"typescript": "^6.0.3"
}
}
38 changes: 38 additions & 0 deletions examples/bun/otp/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { makeOpaqueSessionManager, makeOTP } from "@repo/spike";
import { db } from "./db";

/** How long someone stays signed in, in seconds */
export const sessionTtl = 30 * 24 * 60 * 60;

export const sessionManager = makeOpaqueSessionManager<{ userId: string }>({
store: async (token, row) => {
await db.sessions.insert({
token,
userId: row.userId,
expiresAt: row.expiresAt,
});
},
get: async (token) => {
const row = await db.sessions.get(token);

return row ? { userId: row.userId, expiresAt: row.expiresAt } : null;
},
delete: async (token) => {
await db.sessions.delete(token);
},
ttl: sessionTtl,
});

/** Signs someone in or up */
export const signInOtp = makeOTP({
purpose: "sign-in",
store: async (row) => {
await db.otps.insert(row);
},
take: async (ticket) => db.otps.delete(ticket),
send: async ({ to, otp }) => {
console.log(`[OTP] Sign in, ${to}: ${otp}`);
},
ttl: 10 * 60,
attempts: 3,
});
26 changes: 26 additions & 0 deletions examples/bun/otp/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* In-memory database
*
* Simple in-memory stores for demonstration purposes. In a real app these
* would be replaced with database queries.
*/
import { makeMemoryTable } from "@repo/spike/demo";

export const db = {
users: makeMemoryTable<{
userId: string;
email: string;
}>("userId"),
sessions: makeMemoryTable<{
token: string;
userId: string;
expiresAt: number;
}>("token"),
otps: makeMemoryTable<{
ticket: string;
identifier: string;
otp: string;
expiresAt: number;
attemptsLeft: number;
}>("ticket"),
};
35 changes: 35 additions & 0 deletions examples/bun/otp/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AuthAX - Bun OTP example</title>
<script type="module" src="./page.ts"></script>
</head>
<body>
<main>
<h1>AuthAX</h1>
<p id="viewer" hidden></p>
<form id="email-form" hidden>
<label>
Email address
<input name="email" type="email" autocomplete="email" required />
</label>
<button>Send one-time password</button>
</form>
<form id="otp-form" hidden>
<label>
One-time password
<input
name="otp"
inputmode="numeric"
autocomplete="one-time-code"
required
/>
</label>
<button>Sign in</button>
</form>
<p id="error" role="alert"></p>
</main>
</body>
</html>
62 changes: 62 additions & 0 deletions examples/bun/otp/src/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const viewerText = document.getElementById("viewer") as HTMLParagraphElement;
const emailForm = document.getElementById("email-form") as HTMLFormElement;
const otpForm = document.getElementById("otp-form") as HTMLFormElement;
const errorText = document.getElementById("error") as HTMLParagraphElement;

// Held between sending the OTP and verifying it
let ticket = "";

async function showViewer() {
const response = await fetch("/api/viewer");
const viewer: { email: string } | null = await response.json();

if (viewer) {
viewerText.textContent = `Signed in as ${viewer.email}`;
viewerText.hidden = false;
emailForm.hidden = true;
otpForm.hidden = true;
} else {
emailForm.hidden = false;
}
}

emailForm.addEventListener("submit", async (event) => {
event.preventDefault();

const response = await fetch("/api/otp/send", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: new FormData(emailForm).get("email") }),
});

if (!response.ok) {
errorText.textContent = "Could not send a one-time password";
return;
}

const body: { ticket: string } = await response.json();
ticket = body.ticket;
errorText.textContent = "";
emailForm.hidden = true;
otpForm.hidden = false;
});

otpForm.addEventListener("submit", async (event) => {
event.preventDefault();

const response = await fetch("/api/otp/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ticket, otp: new FormData(otpForm).get("otp") }),
});

if (!response.ok) {
errorText.textContent = "Wrong or expired one-time password";
return;
}

errorText.textContent = "";
await showViewer();
});

await showViewer();
108 changes: 108 additions & 0 deletions examples/bun/otp/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { db } from "./db";
import { sessionManager, sessionTtl, signInOtp } from "./auth";
import page from "./index.html";

/**
* Every origin the page is served from. Not read from the request, since
* behind a proxy the request's URL holds the internal address.
*/
const origins = ["http://localhost:3108"];

const server = Bun.serve({
port: 3108,
development: process.env.NODE_ENV !== "production",
routes: {
"/": page,

/** Sends an OTP to the email address and answers the ticket */
"/api/otp/send": {
POST: async (req) => {
// A page on another site can post here too
const origin = req.headers.get("origin");

if (origin === null || !origins.includes(origin)) {
return new Response(null, { status: 403 });
}

const body: unknown = await req.json();

if (
typeof body !== "object" ||
body === null ||
!("email" in body) ||
typeof body.email !== "string"
) {
return new Response(null, { status: 400 });
}

const ticket = await signInOtp.send(body.email);

return Response.json({ ticket });
},
},

/** Checks the OTP, finds or creates the user, and signs them in */
"/api/otp/verify": {
POST: async (req) => {
// A page on another site could sign the visitor in to its own account
const origin = req.headers.get("origin");

if (origin === null || !origins.includes(origin)) {
return new Response(null, { status: 403 });
}

const body: unknown = await req.json();

if (
typeof body !== "object" ||
body === null ||
!("ticket" in body) ||
!("otp" in body) ||
typeof body.ticket !== "string" ||
typeof body.otp !== "string"
) {
return new Response(null, { status: 400 });
}

const verified = await signInOtp.verify({
ticket: body.ticket,
otp: body.otp,
});

if (!verified.success) {
return Response.json({ error: verified.error }, { status: 400 });
}

const { row: user } = await db.users.findOrInsert(
{ email: verified.data.identifier },
{ userId: crypto.randomUUID(), email: verified.data.identifier },
);

const token = await sessionManager.make({ userId: user.userId });

req.cookies.set("session", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: sessionTtl,
});

return Response.json({ email: user.email });
},
},

/** Who is signed in, null when nobody is */
"/api/viewer": {
GET: async (req) => {
const token = req.cookies.get("session");
const session = token ? await sessionManager.get(token) : null;
const user = session ? await db.users.get(session.userId) : null;

return Response.json(user ? { email: user.email } : null);
},
},
},
});

console.log(`Listening on ${server.url}`);
22 changes: 22 additions & 0 deletions examples/bun/otp/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM"],
"types": ["bun"],

"moduleDetection": "force",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}
2 changes: 1 addition & 1 deletion examples/convex-react/otp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^6.0.3",
"vite": "^8.3.1"
"vite": "^8.3.2"
}
}
Loading
Loading