Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
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
10 changes: 7 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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;
}
}
8 changes: 0 additions & 8 deletions examples/convex-react/otp/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/nextjs/otp-memory/app/globals.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;
}
}
7 changes: 7 additions & 0 deletions examples/shared/react/src/styles.css
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
@source ".";

@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
2 changes: 1 addition & 1 deletion examples/tanstack-start-react/otp-memory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"typecheck": "tsc"
},
"dependencies": {
"authax": "workspace:*",
"@repo/shared-react": "workspace:*",
"@repo/spike": "workspace:*",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-router": "^1.170.39",
"@tanstack/react-start": "^1.168.58",
Expand Down
57 changes: 41 additions & 16 deletions examples/tanstack-start-react/otp-memory/src/auth-rpc.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { db } from "./db";
import { auth, emailOtp } from "./auth";
import { emailOtp, sessionManager } from "./auth";
import { sessionCookie } from "./session-cookie";

/** The session behind the request's cookie, null when there is none */
async function getIdentity() {
const token = sessionCookie.get();

return token === null ? null : sessionManager.get(token);
}

/**
* Request OTP schema
*/
Expand All @@ -15,34 +22,47 @@ export const requestOtpSchema = z.object({
* Verify OTP schema
*/
export const verifyOtpSchema = z.object({
identifier: z.email(),
ticket: z.string(),
otp: z.string().length(6),
});

/**
* Send OTP to identifier server function
*
* Returns the ticket the client sends back with the OTP.
*/
export const requestOtp = createServerFn({ method: "POST" })
.validator(requestOtpSchema)
.handler(({ data }) => auth.strategies.email.request(data));
.handler(async ({ data }) => ({
success: true,
ticket: await emailOtp.send(data.identifier),
}));

/**
* Verify OTP server function
*
* Authenticates with the OTP, which upserts the user and establishes a
* Authenticates with the OTP, finds or creates the user, and establishes a
* session. Returns isNew to distinguish sign-up from sign-in (for analytics,
* onboarding, etc.).
*/
export const verifyOtp = createServerFn({ method: "POST" })
.validator(verifyOtpSchema)
.handler(async ({ data }) => {
const result = await auth.strategies.email.authenticate(data);
const result = await emailOtp.verify(data);

if (!result.success) return { success: false };

sessionCookie.set(result.data.session.token, result.data.session.expiresAt);
const { identifier } = result.data.proven;
const { row: user, isNew } = await db.users.findOrInsert(
{ email: identifier },
{ userId: crypto.randomUUID(), email: identifier },
);

sessionCookie.set(
await sessionManager.make(result.data, { userId: user.userId }),
);

return { success: true, isNew: result.data.user.isNew };
return { success: true, isNew };
});

/**
Expand All @@ -54,13 +74,16 @@ export const verifyOtp = createServerFn({ method: "POST" })
export const changeEmail = createServerFn({ method: "POST" })
.validator(verifyOtpSchema)
.handler(async ({ data }) => {
const identity = await auth.session.get(sessionCookie.get());
const identity = await getIdentity();
if (!identity) return { success: false };

const verified = await emailOtp.verify(data.identifier, data.otp);
if (!verified) return { success: false };
const verified = await emailOtp.verify(data);
if (!verified.success) return { success: false };

const user = db.users.updateEmail(identity.userId, data.identifier);
const user = await db.users.updateEmail(
identity.userId,
verified.data.proven.identifier,
);
if (!user) return { success: false };

return { success: true, viewer: user };
Expand All @@ -72,7 +95,9 @@ export const changeEmail = createServerFn({ method: "POST" })
* Ends the current session and clears the session cookie.
*/
export const signOut = createServerFn({ method: "POST" }).handler(async () => {
await auth.session.end(sessionCookie.get());
const token = sessionCookie.get();

if (token !== null) await sessionManager.end(token);
sessionCookie.clear();
});

Expand All @@ -83,8 +108,8 @@ export const signOut = createServerFn({ method: "POST" }).handler(async () => {
*/
export const signOutAll = createServerFn({ method: "POST" }).handler(
async () => {
const identity = await auth.session.get(sessionCookie.get());
if (identity) db.sessions.deleteAllForUser(identity.userId);
const identity = await getIdentity();
if (identity) await db.sessions.deleteAllForUser(identity.userId);
sessionCookie.clear();
},
);
Expand All @@ -95,7 +120,7 @@ export const signOutAll = createServerFn({ method: "POST" }).handler(
* Returns the current user if authenticated, or null otherwise.
*/
export const getViewer = createServerFn().handler(async () => {
const identity = await auth.session.get(sessionCookie.get());
const identity = await getIdentity();

return identity ? (db.users.get(identity.userId) ?? null) : null;
return identity ? db.users.get(identity.userId) : null;
});
73 changes: 47 additions & 26 deletions examples/tanstack-start-react/otp-memory/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,55 @@
import { makeAuth, makeOpaqueSession, makeOtp, makeOtpStrategy } from "authax";
import { makeOpaqueSessionManager, makeOTP } from "@repo/spike";
import { db } from "./db";

const session = makeOpaqueSession({
storage: db.sessions,
ttl: 30 * 24 * 60 * 60 * 1000,
/** How long someone stays signed in, in ms. The cookie lives as long. */
export const sessionTtl = 30 * 24 * 60 * 60 * 1000;

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

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

export const emailOtp = makeOtp({
storage: db.otps,
delivery: {
send: async (identifier, otp) => {
console.log(`[OTP] ${identifier}: ${otp}`);
},
export const emailOtp = makeOTP({
store: async (ticket, row) => {
await db.otps.insert({
id: ticket,
email: row.identifier,
otp: row.otp,
expiresAt: new Date(row.expiresAt),
attemptsLeft: row.attemptsLeft,
});
},
take: async (ticket) => {
const row = await db.otps.delete(ticket);

return row
? {
identifier: row.email,
otp: row.otp,
expiresAt: row.expiresAt.getTime(),
attemptsLeft: row.attemptsLeft,
}
: null;
},
send: async (identifier, otp) => {
console.log(`[OTP] ${identifier}: ${otp}`);
},
ttl: 10 * 60 * 1000,
attempts: 3,
});

export const auth = makeAuth(session, (kernel) => ({
email: makeOtpStrategy(kernel, {
request: async ({ identifier }) => {
await emailOtp.request(identifier);
return { success: true };
},
authenticate: async ({ identifier, otp }) => {
if (!(await emailOtp.verify(identifier, otp))) {
return { success: false, error: "invalid_otp" };
}

return { success: true, data: db.users.upsert(identifier) };
},
}),
}));
Loading
Loading