From e8c77aa5c1aa2e7c91b3240067f45cd68585c93c Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 14:22:20 -0700 Subject: [PATCH 01/43] Create spike.ts --- spike.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 spike.ts diff --git a/spike.ts b/spike.ts new file mode 100644 index 0000000..c498b7e --- /dev/null +++ b/spike.ts @@ -0,0 +1,96 @@ +const _email = "ripley@example.com"; +let _interceptedOneTimePasscode = ""; + +/** + * Session + */ + +// Opaque session. Remembers the email under a random id. +const sessionsTable = new Map(); + +const opaque = { + make: async ({ email }: { email: string }) => { + console.log("making session", email); + + const id = crypto.randomUUID(); + sessionsTable.set(id, { email }); + + console.log("session made", id, email); + return id; + }, + + get: async (id: string) => { + console.log("getting session", id); + return sessionsTable.get(id) ?? null; + }, +}; + +/** + * OTP strategy + */ + +// OTP. One code per email, checked once. +const codesTable = new Map(); + +const otp = { + send: async (email: string) => { + console.log("sending", email); + const otp = crypto.randomUUID(); + codesTable.set(email, otp); + + // Capture the code for demo + _interceptedOneTimePasscode = otp; + + // send the code to the email address + console.log("sent", email, otp); + }, + verify: async ({ email, code }: { email: string; code: string }) => { + console.log("verifying", email, code); + + const ok = codesTable.get(email) === code; + + codesTable.delete(email); + + console.log("verified", email, ok); + return ok; + }, +}; + +/** + * Core + */ + +/** Takes an input and returns true or false. */ +type Authenticate = (input: Input) => Promise; + +/** Takes something to remember and returns a session id. */ +type MakeSession = (input: Input) => Promise; + +/** The rule. A session is made only when authenticate returned true. */ +function core( + authenticate: Authenticate, + makeSession: MakeSession, +) { + return async (input: Input): Promise => { + if (!(await authenticate(input))) return null; + + return makeSession(input); + }; +} + +// +// Playground +// + +console.log("-".repeat(80)); + +await otp.send(_email); + +export const signIn = core(otp.verify, opaque.make); + +const sessionId = await signIn({ + email: _email, + code: _interceptedOneTimePasscode, +}); +console.log("sessionId", sessionId); +console.log("SESSION", sessionId ? await opaque.get(sessionId) : null); From 130583bba9cb2faaad1fc1cdaf357964ca1d5b3d Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 14:45:49 -0700 Subject: [PATCH 02/43] Update spike.ts --- spike.ts | 53 +++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/spike.ts b/spike.ts index c498b7e..b0e6fe2 100644 --- a/spike.ts +++ b/spike.ts @@ -29,30 +29,34 @@ const opaque = { * OTP strategy */ -// OTP. One code per email, checked once. -const codesTable = new Map(); +// OTP. One row per sent otp, keyed by a random id. Checked once. +const otpsTable = new Map(); const otp = { send: async (email: string) => { console.log("sending", email); + const id = crypto.randomUUID(); const otp = crypto.randomUUID(); - codesTable.set(email, otp); + otpsTable.set(id, { email, otp: otp }); - // Capture the code for demo + // Capture the otp for demo _interceptedOneTimePasscode = otp; - // send the code to the email address + // send the otp to the email address console.log("sent", email, otp); + return id; }, - verify: async ({ email, code }: { email: string; code: string }) => { - console.log("verifying", email, code); + verify: async ({ id, otp }: { id: string; otp: string }) => { + console.log("verifying", id, otp); + + const row = otpsTable.get(id) ?? null; - const ok = codesTable.get(email) === code; + otpsTable.delete(id); - codesTable.delete(email); + const ok = row !== null && row.otp === otp; - console.log("verified", email, ok); - return ok; + console.log("verified", row?.email, ok); + return ok && row !== null ? { email: row.email } : null; }, }; @@ -60,21 +64,22 @@ const otp = { * Core */ -/** Takes an input and returns true or false. */ -type Authenticate = (input: Input) => Promise; +/** Takes an input and returns what was proven, or null. */ +type Authenticate = (input: Input) => Promise; -/** Takes something to remember and returns a session id. */ -type MakeSession = (input: Input) => Promise; +/** Takes what was proven and returns a session id. */ +type MakeSession = (proven: Proven) => Promise; -/** The rule. A session is made only when authenticate returned true. */ -function core( - authenticate: Authenticate, - makeSession: MakeSession, +/** The rule. A session is made only when authenticate returned something. */ +function core( + authenticate: Authenticate, + makeSession: MakeSession, ) { return async (input: Input): Promise => { - if (!(await authenticate(input))) return null; + const proven = await authenticate(input); + if (proven === null) return null; - return makeSession(input); + return makeSession(proven); }; } @@ -84,13 +89,13 @@ function core( console.log("-".repeat(80)); -await otp.send(_email); +const otpId = await otp.send(_email); export const signIn = core(otp.verify, opaque.make); const sessionId = await signIn({ - email: _email, - code: _interceptedOneTimePasscode, + id: otpId, + otp: _interceptedOneTimePasscode, }); console.log("sessionId", sessionId); console.log("SESSION", sessionId ? await opaque.get(sessionId) : null); From f2764fdf81e23359549bc83b2616afe19f77c9a5 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 16:29:34 -0700 Subject: [PATCH 03/43] Add users table to spike --- spike.ts | 73 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/spike.ts b/spike.ts index b0e6fe2..da9ebb3 100644 --- a/spike.ts +++ b/spike.ts @@ -1,27 +1,62 @@ const _email = "ripley@example.com"; let _interceptedOneTimePasscode = ""; +function createTable() { + const data = new Map(); + + const insert = (row: V) => { + const record = { ...row, id: crypto.randomUUID() }; + data.set(record.id, record); + return record; + }; + + return { + get: async (id: string) => data.get(id) ?? null, + insert: async (row: V) => insert(row), + upsert: async (key: keyof V, row: V) => { + for (const [id, current] of data) { + if (current[key] === row[key]) { + const record = { ...row, id }; + data.set(id, record); + return record; + } + } + return insert(row); + }, + delete: async (id: string) => { + const row = data.get(id) ?? null; + data.delete(id); + return row; + }, + }; +} + +// The app's own table. Keyed by a random id, email is a column. +const usersTable = createTable<{ email: string }>(); + +// Opaque session. Remembers the user id under a random id. +const sessionsTable = createTable<{ userId: string }>(); + +// OTP. One row per sent otp, keyed by a random id. Checked once. +const otpsTable = createTable<{ email: string; otp: string }>(); + /** * Session */ -// Opaque session. Remembers the email under a random id. -const sessionsTable = new Map(); - const opaque = { - make: async ({ email }: { email: string }) => { - console.log("making session", email); + make: async ({ userId }: { userId: string }) => { + console.log("making session", userId); - const id = crypto.randomUUID(); - sessionsTable.set(id, { email }); + const { id } = await sessionsTable.insert({ userId }); - console.log("session made", id, email); + console.log("session made", id, userId); return id; }, get: async (id: string) => { console.log("getting session", id); - return sessionsTable.get(id) ?? null; + return sessionsTable.get(id); }, }; @@ -29,15 +64,11 @@ const opaque = { * OTP strategy */ -// OTP. One row per sent otp, keyed by a random id. Checked once. -const otpsTable = new Map(); - const otp = { send: async (email: string) => { console.log("sending", email); - const id = crypto.randomUUID(); const otp = crypto.randomUUID(); - otpsTable.set(id, { email, otp: otp }); + const { id } = await otpsTable.insert({ email, otp }); // Capture the otp for demo _interceptedOneTimePasscode = otp; @@ -49,9 +80,7 @@ const otp = { verify: async ({ id, otp }: { id: string; otp: string }) => { console.log("verifying", id, otp); - const row = otpsTable.get(id) ?? null; - - otpsTable.delete(id); + const row = await otpsTable.delete(id); const ok = row !== null && row.otp === otp; @@ -91,11 +120,17 @@ console.log("-".repeat(80)); const otpId = await otp.send(_email); -export const signIn = core(otp.verify, opaque.make); +export const signIn = core(otp.verify, async ({ email }) => { + const user = await usersTable.upsert("email", { email }); + return opaque.make({ userId: user.id }); +}); const sessionId = await signIn({ id: otpId, otp: _interceptedOneTimePasscode, }); console.log("sessionId", sessionId); -console.log("SESSION", sessionId ? await opaque.get(sessionId) : null); + +const session = sessionId ? await opaque.get(sessionId) : null; +console.log("SESSION", session); +console.log("USER", session ? await usersTable.get(session.userId) : null); From cfbbc560f98e9abc16c03ca75b4cb61939473e55 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 16:35:55 -0700 Subject: [PATCH 04/43] Create spike-no-kernel.ts --- spike-no-kernel.ts | 134 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 spike-no-kernel.ts diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts new file mode 100644 index 0000000..c7050bd --- /dev/null +++ b/spike-no-kernel.ts @@ -0,0 +1,134 @@ +const _email = "ripley@example.com"; +let _interceptedOneTimePasscode = ""; + +function createTable() { + const data = new Map(); + + const insert = (row: V) => { + const record = { ...row, id: crypto.randomUUID() }; + data.set(record.id, record); + return record; + }; + + return { + get: async (id: string) => data.get(id) ?? null, + insert: async (row: V) => insert(row), + upsert: async (key: keyof V, row: V) => { + for (const [id, current] of data) { + if (current[key] === row[key]) { + const record = { ...row, id }; + data.set(id, record); + return record; + } + } + return insert(row); + }, + delete: async (id: string) => { + const row = data.get(id) ?? null; + data.delete(id); + return row; + }, + }; +} + +// The app's own table. Keyed by a random id, email is a column. +const usersTable = createTable<{ email: string }>(); + +// Opaque session. Remembers the user id under a random id. +const sessionsTable = createTable<{ userId: string }>(); + +// OTP. One row per sent otp, keyed by a random id. Checked once. +const otpsTable = createTable<{ email: string; otp: string }>(); + +/** + * Proof + */ + +const proof = Symbol("proof"); + +/** Only a strategy can produce one. A session cannot be made without one. */ +type Proof = { readonly [proof]: true }; + +/** What a strategy proved, marked as a proof */ +type Proven = T & Proof; + +/** Strategies call this. In a package, the symbol is not exported, so nothing else can. */ +function prove(value: T): Proven { + return { ...value, [proof]: true }; +} + +/** + * Session + */ + +const opaque = { + make: async (_: Proof, { userId }: { userId: string }) => { + console.log("making session", userId); + + const { id } = await sessionsTable.insert({ userId }); + + return id; + }, + + get: async (id: string) => { + console.log("getting session", id); + return sessionsTable.get(id); + }, +}; + +/** + * OTP strategy + */ + +const otp = { + send: async (email: string) => { + console.log("sending", email); + const otp = crypto.randomUUID(); + const { id } = await otpsTable.insert({ email, otp }); + + // Capture the otp for demo + _interceptedOneTimePasscode = otp; + + // send the otp to the email address + console.log("sent", email, otp); + return id; + }, + verify: async ({ id, otp }: { id: string; otp: string }) => { + console.log("verifying", id, otp); + + const row = await otpsTable.delete(id); + + const ok = row !== null && row.otp === otp; + + console.log("verified", row?.email, ok); + return ok && row !== null ? prove({ email: row.email }) : null; + }, +}; + +// +// Playground +// + +console.log("-".repeat(80)); + +const otpId = await otp.send(_email); + +const proven = await otp.verify({ + id: otpId, + otp: _interceptedOneTimePasscode, +}); +if (proven === null) throw new Error("wrong otp"); + +const user = await usersTable.upsert("email", { email: proven.email }); +const sessionId = await opaque.make(proven, { userId: user.id }); +console.log("sessionId", sessionId); + +const session = await opaque.get(sessionId); +console.log("SESSION", session); +console.log("USER", session ? await usersTable.get(session.userId) : null); + +// The guard. Never called, it exists to show what does not compile. +export function withoutProof(userId: string) { + // @ts-expect-error a session cannot be made without a proof + return opaque.make({ userId }, { userId }); +} From 1a8641d41067c919aafc37263caac51a16745728 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 16:38:40 -0700 Subject: [PATCH 05/43] Create tsconfig.json --- tsconfig.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tsconfig.json diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a6d907a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + /* Base */ + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + }, + "include": ["spike.ts", "spike-no-kernel.ts"], +} From 2ff8e873a89574bfc442899bf376a78323be5157 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 16:44:28 -0700 Subject: [PATCH 06/43] Refactor --- spike-helpers.ts | 29 +++++++++++++++++++++++++++++ spike-no-kernel.ts | 32 ++------------------------------ spike.ts | 32 ++------------------------------ tsconfig.json | 2 +- 4 files changed, 34 insertions(+), 61 deletions(-) create mode 100644 spike-helpers.ts diff --git a/spike-helpers.ts b/spike-helpers.ts new file mode 100644 index 0000000..7f2e7f3 --- /dev/null +++ b/spike-helpers.ts @@ -0,0 +1,29 @@ +export function createTable() { + const data = new Map(); + + const insert = (row: V) => { + const record = { ...row, id: crypto.randomUUID() }; + data.set(record.id, record); + return record; + }; + + return { + get: async (id: string) => data.get(id) ?? null, + insert: async (row: V) => insert(row), + upsert: async (key: keyof V, row: V) => { + for (const [id, current] of data) { + if (current[key] === row[key]) { + const record = { ...row, id }; + data.set(id, record); + return record; + } + } + return insert(row); + }, + delete: async (id: string) => { + const row = data.get(id) ?? null; + data.delete(id); + return row; + }, + }; +} diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index c7050bd..e413ba3 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -1,36 +1,8 @@ +import { createTable } from "./spike-helpers"; + const _email = "ripley@example.com"; let _interceptedOneTimePasscode = ""; -function createTable() { - const data = new Map(); - - const insert = (row: V) => { - const record = { ...row, id: crypto.randomUUID() }; - data.set(record.id, record); - return record; - }; - - return { - get: async (id: string) => data.get(id) ?? null, - insert: async (row: V) => insert(row), - upsert: async (key: keyof V, row: V) => { - for (const [id, current] of data) { - if (current[key] === row[key]) { - const record = { ...row, id }; - data.set(id, record); - return record; - } - } - return insert(row); - }, - delete: async (id: string) => { - const row = data.get(id) ?? null; - data.delete(id); - return row; - }, - }; -} - // The app's own table. Keyed by a random id, email is a column. const usersTable = createTable<{ email: string }>(); diff --git a/spike.ts b/spike.ts index da9ebb3..56c7d4f 100644 --- a/spike.ts +++ b/spike.ts @@ -1,36 +1,8 @@ +import { createTable } from "./spike-helpers"; + const _email = "ripley@example.com"; let _interceptedOneTimePasscode = ""; -function createTable() { - const data = new Map(); - - const insert = (row: V) => { - const record = { ...row, id: crypto.randomUUID() }; - data.set(record.id, record); - return record; - }; - - return { - get: async (id: string) => data.get(id) ?? null, - insert: async (row: V) => insert(row), - upsert: async (key: keyof V, row: V) => { - for (const [id, current] of data) { - if (current[key] === row[key]) { - const record = { ...row, id }; - data.set(id, record); - return record; - } - } - return insert(row); - }, - delete: async (id: string) => { - const row = data.get(id) ?? null; - data.delete(id); - return row; - }, - }; -} - // The app's own table. Keyed by a random id, email is a column. const usersTable = createTable<{ email: string }>(); diff --git a/tsconfig.json b/tsconfig.json index a6d907a..48f6039 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, }, - "include": ["spike.ts", "spike-no-kernel.ts"], + "include": ["spike.ts", "spike-no-kernel.ts", "spike-helpers.ts"], } From 3d2b582f3158441ef30ad917e964bf05612d8233 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 16:49:32 -0700 Subject: [PATCH 07/43] Update tsconfig.json --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 48f6039..9a049ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, + "noFallthroughCasesInSwitch": true }, - "include": ["spike.ts", "spike-no-kernel.ts", "spike-helpers.ts"], + "include": ["spike.ts", "spike-no-kernel.ts", "spike-helpers.ts"] } From 6793ac23b9342417d2725e66acc6117c37e55102 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 17:11:17 -0700 Subject: [PATCH 08/43] Update spike-no-kernel.ts --- spike-no-kernel.ts | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index e413ba3..5f5e3f0 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -4,7 +4,7 @@ const _email = "ripley@example.com"; let _interceptedOneTimePasscode = ""; // The app's own table. Keyed by a random id, email is a column. -const usersTable = createTable<{ email: string }>(); +// const usersTable = createTable<{ name: string }>(); // Opaque session. Remembers the user id under a random id. const sessionsTable = createTable<{ userId: string }>(); @@ -35,15 +35,12 @@ function prove(value: T): Proven { const opaque = { make: async (_: Proof, { userId }: { userId: string }) => { - console.log("making session", userId); + const sessionRow = await sessionsTable.insert({ userId }); - const { id } = await sessionsTable.insert({ userId }); - - return id; + return sessionRow.id; }, get: async (id: string) => { - console.log("getting session", id); return sessionsTable.get(id); }, }; @@ -54,25 +51,22 @@ const opaque = { const otp = { send: async (email: string) => { - console.log("sending", email); const otp = crypto.randomUUID(); const { id } = await otpsTable.insert({ email, otp }); // Capture the otp for demo _interceptedOneTimePasscode = otp; - // send the otp to the email address - console.log("sent", email, otp); + // Simulate sending the otp to the email address + console.log("sent otp to:", email, "otp:", otp); + return id; }, verify: async ({ id, otp }: { id: string; otp: string }) => { - console.log("verifying", id, otp); - const row = await otpsTable.delete(id); const ok = row !== null && row.otp === otp; - console.log("verified", row?.email, ok); return ok && row !== null ? prove({ email: row.email }) : null; }, }; @@ -83,21 +77,21 @@ const otp = { console.log("-".repeat(80)); +// 1. Request OTP - server function const otpId = await otp.send(_email); +// 2. Verify OTP - server function const proven = await otp.verify({ id: otpId, otp: _interceptedOneTimePasscode, }); if (proven === null) throw new Error("wrong otp"); +console.log("proven", proven); +const sessionId = await opaque.make(proven, { userId: "some-user-identifier" }); -const user = await usersTable.upsert("email", { email: proven.email }); -const sessionId = await opaque.make(proven, { userId: user.id }); -console.log("sessionId", sessionId); - +// 3. Get the session and the user - server function const session = await opaque.get(sessionId); console.log("SESSION", session); -console.log("USER", session ? await usersTable.get(session.userId) : null); // The guard. Never called, it exists to show what does not compile. export function withoutProof(userId: string) { From 2b3b0a6ee5f1f4642e6faab7248aeac410d50fcd Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 17:21:34 -0700 Subject: [PATCH 09/43] Update spike-no-kernel.ts --- spike-no-kernel.ts | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index 5f5e3f0..b2e61ad 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -16,17 +16,21 @@ const otpsTable = createTable<{ email: string; otp: string }>(); * Proof */ -const proof = Symbol("proof"); - -/** Only a strategy can produce one. A session cannot be made without one. */ -type Proof = { readonly [proof]: true }; +/** + * What a strategy proved. Only a strategy can construct one, and a session + * cannot be made without one. In a package, only the type is exported, so + * the factory is reachable from strategies alone. + */ +class Proof { + readonly proven: T; -/** What a strategy proved, marked as a proof */ -type Proven = T & Proof; + private constructor(proven: T) { + this.proven = proven; + } -/** Strategies call this. In a package, the symbol is not exported, so nothing else can. */ -function prove(value: T): Proven { - return { ...value, [proof]: true }; + static prove(proven: T) { + return new Proof(proven); + } } /** @@ -34,7 +38,9 @@ function prove(value: T): Proven { */ const opaque = { - make: async (_: Proof, { userId }: { userId: string }) => { + make: async (proof: Proof, { userId }: { userId: string }) => { + if (!(proof instanceof Proof)) throw new Error("not a proof"); + const sessionRow = await sessionsTable.insert({ userId }); return sessionRow.id; @@ -67,7 +73,7 @@ const otp = { const ok = row !== null && row.otp === otp; - return ok && row !== null ? prove({ email: row.email }) : null; + return ok && row !== null ? Proof.prove({ email: row.email }) : null; }, }; @@ -81,13 +87,13 @@ console.log("-".repeat(80)); const otpId = await otp.send(_email); // 2. Verify OTP - server function -const proven = await otp.verify({ +const proof = await otp.verify({ id: otpId, otp: _interceptedOneTimePasscode, }); -if (proven === null) throw new Error("wrong otp"); -console.log("proven", proven); -const sessionId = await opaque.make(proven, { userId: "some-user-identifier" }); +if (proof === null) throw new Error("wrong otp"); +console.log("proof", proof); +const sessionId = await opaque.make(proof, { userId: "some-user-identifier" }); // 3. Get the session and the user - server function const session = await opaque.get(sessionId); From bceb70b0a7708d4296ecb9bdbb57ac2cc5bb7df0 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 17:36:53 -0700 Subject: [PATCH 10/43] Update spike-no-kernel.ts --- spike-no-kernel.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index b2e61ad..e227220 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -23,6 +23,7 @@ const otpsTable = createTable<{ email: string; otp: string }>(); */ class Proof { readonly proven: T; + private used = false; private constructor(proven: T) { this.proven = proven; @@ -31,6 +32,12 @@ class Proof { static prove(proven: T) { return new Proof(proven); } + + /** A proof is spent by the one call that uses it. A second call throws. */ + consume() { + if (this.used) throw new Error("proof already used"); + this.used = true; + } } /** @@ -38,10 +45,14 @@ class Proof { */ const opaque = { - make: async (proof: Proof, { userId }: { userId: string }) => { + make: async ( + proof: Proof, + resolve: (proven: T) => Promise<{ userId: string }>, + ) => { if (!(proof instanceof Proof)) throw new Error("not a proof"); + proof.consume(); - const sessionRow = await sessionsTable.insert({ userId }); + const sessionRow = await sessionsTable.insert(await resolve(proof.proven)); return sessionRow.id; }, @@ -93,12 +104,22 @@ const proof = await otp.verify({ }); if (proof === null) throw new Error("wrong otp"); console.log("proof", proof); -const sessionId = await opaque.make(proof, { userId: "some-user-identifier" }); +const sessionId = await opaque.make(proof, async ({ email }) => ({ + userId: `user-for-${email}`, +})); +console.log("proof", proof); // 3. Get the session and the user - server function const session = await opaque.get(sessionId); console.log("SESSION", session); +// 4. Reuse the proof - rejected at runtime +try { + await opaque.make(proof, async () => ({ userId: "someone-else" })); +} catch (error) { + console.log("REUSE", error instanceof Error ? error.message : error); +} + // The guard. Never called, it exists to show what does not compile. export function withoutProof(userId: string) { // @ts-expect-error a session cannot be made without a proof From a44285e7ff5f476f46e21206d6a73bff7db31ce8 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:02:28 -0700 Subject: [PATCH 11/43] Add factories to spike --- spike-no-kernel.ts | 168 ++++++++++++++++++++++++++++----------------- 1 file changed, 106 insertions(+), 62 deletions(-) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index e227220..93ded96 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -1,17 +1,5 @@ import { createTable } from "./spike-helpers"; -const _email = "ripley@example.com"; -let _interceptedOneTimePasscode = ""; - -// The app's own table. Keyed by a random id, email is a column. -// const usersTable = createTable<{ name: string }>(); - -// Opaque session. Remembers the user id under a random id. -const sessionsTable = createTable<{ userId: string }>(); - -// OTP. One row per sent otp, keyed by a random id. Checked once. -const otpsTable = createTable<{ email: string; otp: string }>(); - /** * Proof */ @@ -41,52 +29,96 @@ class Proof { } /** - * Session + * Session factory */ -const opaque = { - make: async ( - proof: Proof, - resolve: (proven: T) => Promise<{ userId: string }>, - ) => { - if (!(proof instanceof Proof)) throw new Error("not a proof"); - proof.consume(); - - const sessionRow = await sessionsTable.insert(await resolve(proof.proven)); +function makeOpaqueSession(args: { + /** Stores a session row and returns its id */ + store: (row: Remembered) => Promise<{ id: string }>; + /** Reads a session row by id, null when there is none */ + get: (id: string) => Promise<(Remembered & { id: string }) | null>; +}) { + return { + make: async ( + proof: Proof, + resolve: (proven: T) => Promise, + ) => { + if (!(proof instanceof Proof)) throw new Error("not a proof"); + proof.consume(); + + const { id } = await args.store(await resolve(proof.proven)); + + return id; + }, + + get: (id: string) => args.get(id), + }; +} - return sessionRow.id; - }, +/** + * OTP factory + */ - get: async (id: string) => { - return sessionsTable.get(id); - }, -}; +function makeOTP(args: { + /** Stores an otp row and returns its id */ + store: (row: { identifier: string; otp: string }) => Promise<{ id: string }>; + /** Removes an otp row by id and returns it, atomically. Null when there is none. */ + take: (id: string) => Promise<{ identifier: string; otp: string } | null>; + /** Delivers the otp to the identifier, an email address or a phone number */ + send: (identifier: string, otp: string) => Promise; +}) { + return { + send: async (identifier: string) => { + const otp = crypto.randomUUID(); + const { id } = await args.store({ identifier, otp }); + + await args.send(identifier, otp); + + return id; + }, + + verify: async ({ id, otp }: { id: string; otp: string }) => { + const row = await args.take(id); + + return row !== null && row.otp === otp + ? Proof.prove({ identifier: row.identifier }) + : null; + }, + }; +} /** - * OTP strategy + * App */ -const otp = { - send: async (email: string) => { - const otp = crypto.randomUUID(); - const { id } = await otpsTable.insert({ email, otp }); +const sessionsTable = createTable<{ userId: string }>(); +const otpsTable = createTable<{ identifier: string; otp: string }>(); - // Capture the otp for demo - _interceptedOneTimePasscode = otp; +// Captures what would have been delivered, for the demo +const delivered = new Map(); - // Simulate sending the otp to the email address - console.log("sent otp to:", email, "otp:", otp); +const opaque = makeOpaqueSession<{ userId: string }>({ + store: (row) => sessionsTable.insert(row), + get: (id) => sessionsTable.get(id), +}); - return id; +const emailOtp = makeOTP({ + store: (row) => otpsTable.insert(row), + take: (id) => otpsTable.delete(id), + send: async (identifier, otp) => { + console.log("email to:", identifier, "otp:", otp); + delivered.set(identifier, otp); }, - verify: async ({ id, otp }: { id: string; otp: string }) => { - const row = await otpsTable.delete(id); - - const ok = row !== null && row.otp === otp; +}); - return ok && row !== null ? Proof.prove({ email: row.email }) : null; +const smsOtp = makeOTP({ + store: (row) => otpsTable.insert(row), + take: (id) => otpsTable.delete(id), + send: async (identifier, otp) => { + console.log("sms to:", identifier, "otp:", otp); + delivered.set(identifier, otp); }, -}; +}); // // Playground @@ -94,28 +126,40 @@ const otp = { console.log("-".repeat(80)); -// 1. Request OTP - server function -const otpId = await otp.send(_email); +// Email. 1. request, 2. verify, 3. session +const emailOtpId = await emailOtp.send("ripley@example.com"); -// 2. Verify OTP - server function -const proof = await otp.verify({ - id: otpId, - otp: _interceptedOneTimePasscode, +const emailProof = await emailOtp.verify({ + id: emailOtpId, + otp: delivered.get("ripley@example.com") ?? "", }); -if (proof === null) throw new Error("wrong otp"); -console.log("proof", proof); -const sessionId = await opaque.make(proof, async ({ email }) => ({ - userId: `user-for-${email}`, -})); -console.log("proof", proof); +if (emailProof === null) throw new Error("wrong otp"); + +const emailSessionId = await opaque.make( + emailProof, + async ({ identifier }) => ({ + userId: `user-for-${identifier}`, + }), +); +console.log("SESSION", await opaque.get(emailSessionId)); + +// SMS. Same three steps, other instance +const smsOtpId = await smsOtp.send("+15555550100"); + +const smsProof = await smsOtp.verify({ + id: smsOtpId, + otp: delivered.get("+15555550100") ?? "", +}); +if (smsProof === null) throw new Error("wrong otp"); -// 3. Get the session and the user - server function -const session = await opaque.get(sessionId); -console.log("SESSION", session); +const smsSessionId = await opaque.make(smsProof, async ({ identifier }) => ({ + userId: `user-for-${identifier}`, +})); +console.log("SESSION", await opaque.get(smsSessionId)); -// 4. Reuse the proof - rejected at runtime +// Reuse the proof. Rejected at runtime try { - await opaque.make(proof, async () => ({ userId: "someone-else" })); + await opaque.make(emailProof, async () => ({ userId: "someone-else" })); } catch (error) { console.log("REUSE", error instanceof Error ? error.message : error); } @@ -123,5 +167,5 @@ try { // The guard. Never called, it exists to show what does not compile. export function withoutProof(userId: string) { // @ts-expect-error a session cannot be made without a proof - return opaque.make({ userId }, { userId }); + return opaque.make({ userId }, async () => ({ userId })); } From 653084049d0c821528a86263ad73a78aa84ea427 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:11:36 -0700 Subject: [PATCH 12/43] Add fake passkeys to spike --- spike-no-kernel.ts | 137 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index 93ded96..b5b00ea 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -87,12 +87,88 @@ function makeOTP(args: { }; } +/** + * Passkey factory + * + * Fake. No WebAuthn, the "signature" is the public key sent back as is. + * Only the shape of the two ceremonies is real. The library stores + * credentials but never who owns them, the app keeps that link, the same + * way it maps an OTP identifier to a user. + */ + +type Purpose = "register" | "authenticate"; + +function makePasskey(args: { + /** Stores a challenge row and returns its id, which is the challenge */ + storeChallenge: (row: { purpose: Purpose }) => Promise<{ id: string }>; + /** Removes a challenge row by id and returns it, atomically. Null when there is none. */ + takeChallenge: (id: string) => Promise<{ purpose: Purpose } | null>; + /** Stores a credential row and returns its id, which is the credential id */ + storeCredential: (row: { publicKey: string }) => Promise<{ id: string }>; + /** Reads a credential row by id, null when there is none */ + getCredential: (id: string) => Promise<{ publicKey: string } | null>; +}) { + return { + beginRegistration: async () => { + const { id } = await args.storeChallenge({ purpose: "register" }); + return { challenge: id }; + }, + + finishRegistration: async ({ + challenge, + publicKey, + }: { + challenge: string; + publicKey: string; + }) => { + const row = await args.takeChallenge(challenge); + if (row === null || row.purpose !== "register") return null; + + const { id } = await args.storeCredential({ publicKey }); + + return Proof.prove({ credentialId: id }); + }, + + beginAuthentication: async () => { + const { id } = await args.storeChallenge({ purpose: "authenticate" }); + return { challenge: id }; + }, + + finishAuthentication: async ({ + challenge, + credentialId, + signature, + }: { + challenge: string; + credentialId: string; + signature: string; + }) => { + const row = await args.takeChallenge(challenge); + if (row === null || row.purpose !== "authenticate") return null; + + const credential = await args.getCredential(credentialId); + if (credential === null || credential.publicKey !== signature) + return null; + + return Proof.prove({ credentialId }); + }, + }; +} + /** * App */ const sessionsTable = createTable<{ userId: string }>(); const otpsTable = createTable<{ identifier: string; otp: string }>(); +const challengesTable = createTable<{ purpose: Purpose }>(); +const credentialsTable = createTable<{ publicKey: string }>(); + +// The app's own link from credential to user. The library never sees it. +const credentialOwners = new Map(); + +// The fake authenticator in the browser. Credential id to its key. +const authenticator = new Map(); // Captures what would have been delivered, for the demo const delivered = new Map(); @@ -120,6 +196,13 @@ const smsOtp = makeOTP({ }, }); +const passkey = makePasskey({ + storeChallenge: (row) => challengesTable.insert(row), + takeChallenge: (id) => challengesTable.delete(id), + storeCredential: (row) => credentialsTable.insert(row), + getCredential: (id) => credentialsTable.get(id), +}); + // // Playground // @@ -157,6 +240,60 @@ const smsSessionId = await opaque.make(smsProof, async ({ identifier }) => ({ })); console.log("SESSION", await opaque.get(smsSessionId)); +// Passkey sign-up. 1. begin, 2. browser creates a credential, 3. finish, +// 4. app creates the user and links the credential, 5. session +const signUp = await passkey.beginRegistration(); +const newKey = crypto.randomUUID(); +const registered = await passkey.finishRegistration({ + challenge: signUp.challenge, + publicKey: newKey, +}); +if (registered === null) throw new Error("registration failed"); +authenticator.set(registered.proven.credentialId, newKey); + +credentialOwners.set(registered.proven.credentialId, "user-ripley"); +const signUpSessionId = await opaque.make( + registered, + async ({ credentialId }) => ({ + userId: credentialOwners.get(credentialId) ?? "", + }), +); +console.log("SESSION", await opaque.get(signUpSessionId)); + +// Passkey sign-in. 1. begin, 2. browser signs, 3. finish, 4. session +const signIn = await passkey.beginAuthentication(); +const [credentialId, key] = authenticator.entries().next().value ?? ["", ""]; +const authenticated = await passkey.finishAuthentication({ + challenge: signIn.challenge, + credentialId, + signature: key, +}); +if (authenticated === null) throw new Error("authentication failed"); + +const signInSessionId = await opaque.make( + authenticated, + async ({ credentialId }) => ({ + userId: credentialOwners.get(credentialId) ?? "", + }), +); +console.log("SESSION", await opaque.get(signInSessionId)); + +// Add a passkey while signed in. The session says who, the app links, no +// new session +const current = await opaque.get(signInSessionId); +if (current === null) throw new Error("not signed in"); + +const add = await passkey.beginRegistration(); +const secondKey = crypto.randomUUID(); +const added = await passkey.finishRegistration({ + challenge: add.challenge, + publicKey: secondKey, +}); +if (added === null) throw new Error("registration failed"); +authenticator.set(added.proven.credentialId, secondKey); +credentialOwners.set(added.proven.credentialId, current.userId); +console.log("OWNERS", credentialOwners); + // Reuse the proof. Rejected at runtime try { await opaque.make(emailProof, async () => ({ userId: "someone-else" })); From 734e1fd19d73e49be9694fc8797d5499fc8f2ffd Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:16:54 -0700 Subject: [PATCH 13/43] Return failure reasons from verify --- spike-no-kernel.ts | 50 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index b5b00ea..dc59e6c 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -28,6 +28,13 @@ class Proof { } } +/** An expected failure the app branches on. The reason names what did not hold. */ +type Failure = { reason: Reason }; + +function fail(reason: Reason): Failure { + return { reason }; +} + /** * Session factory */ @@ -80,9 +87,10 @@ function makeOTP(args: { verify: async ({ id, otp }: { id: string; otp: string }) => { const row = await args.take(id); - return row !== null && row.otp === otp - ? Proof.prove({ identifier: row.identifier }) - : null; + if (row === null) return fail("unknown"); + if (row.otp !== otp) return fail("mismatch"); + + return Proof.prove({ identifier: row.identifier }); }, }; } @@ -122,7 +130,9 @@ function makePasskey(args: { publicKey: string; }) => { const row = await args.takeChallenge(challenge); - if (row === null || row.purpose !== "register") return null; + + if (row === null) return fail("challenge"); + if (row.purpose !== "register") return fail("challenge"); const { id } = await args.storeCredential({ publicKey }); @@ -144,11 +154,14 @@ function makePasskey(args: { signature: string; }) => { const row = await args.takeChallenge(challenge); - if (row === null || row.purpose !== "authenticate") return null; + + if (row === null) return fail("challenge"); + if (row.purpose !== "authenticate") return fail("challenge"); const credential = await args.getCredential(credentialId); - if (credential === null || credential.publicKey !== signature) - return null; + + if (credential === null) return fail("credential"); + if (credential.publicKey !== signature) return fail("signature"); return Proof.prove({ credentialId }); }, @@ -216,7 +229,7 @@ const emailProof = await emailOtp.verify({ id: emailOtpId, otp: delivered.get("ripley@example.com") ?? "", }); -if (emailProof === null) throw new Error("wrong otp"); +if (!(emailProof instanceof Proof)) throw new Error(emailProof.reason); const emailSessionId = await opaque.make( emailProof, @@ -233,7 +246,7 @@ const smsProof = await smsOtp.verify({ id: smsOtpId, otp: delivered.get("+15555550100") ?? "", }); -if (smsProof === null) throw new Error("wrong otp"); +if (!(smsProof instanceof Proof)) throw new Error(smsProof.reason); const smsSessionId = await opaque.make(smsProof, async ({ identifier }) => ({ userId: `user-for-${identifier}`, @@ -248,7 +261,7 @@ const registered = await passkey.finishRegistration({ challenge: signUp.challenge, publicKey: newKey, }); -if (registered === null) throw new Error("registration failed"); +if (!(registered instanceof Proof)) throw new Error(registered.reason); authenticator.set(registered.proven.credentialId, newKey); credentialOwners.set(registered.proven.credentialId, "user-ripley"); @@ -268,7 +281,7 @@ const authenticated = await passkey.finishAuthentication({ credentialId, signature: key, }); -if (authenticated === null) throw new Error("authentication failed"); +if (!(authenticated instanceof Proof)) throw new Error(authenticated.reason); const signInSessionId = await opaque.make( authenticated, @@ -289,11 +302,24 @@ const added = await passkey.finishRegistration({ challenge: add.challenge, publicKey: secondKey, }); -if (added === null) throw new Error("registration failed"); +if (!(added instanceof Proof)) throw new Error(added.reason); authenticator.set(added.proven.credentialId, secondKey); credentialOwners.set(added.proven.credentialId, current.userId); console.log("OWNERS", credentialOwners); +// Failures come back with a reason +const wrongOtpId = await emailOtp.send("ripley@example.com"); +console.log("WRONG", await emailOtp.verify({ id: wrongOtpId, otp: "nope" })); +console.log("USED", await emailOtp.verify({ id: wrongOtpId, otp: "nope" })); +console.log( + "STRANGER", + await passkey.finishAuthentication({ + challenge: (await passkey.beginAuthentication()).challenge, + credentialId: "not-a-credential", + signature: "", + }), +); + // Reuse the proof. Rejected at runtime try { await opaque.make(emailProof, async () => ({ userId: "someone-else" })); From a43fbd94e6678d7b3dfde6a25508a79372c02484 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:20:07 -0700 Subject: [PATCH 14/43] Shape passkey factory like WebAuthn --- spike-helpers.ts | 6 ++++++ spike-no-kernel.ts | 46 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/spike-helpers.ts b/spike-helpers.ts index 7f2e7f3..84c2280 100644 --- a/spike-helpers.ts +++ b/spike-helpers.ts @@ -10,6 +10,12 @@ export function createTable() { return { get: async (id: string) => data.get(id) ?? null, insert: async (row: V) => insert(row), + /** Insert with a caller supplied id */ + put: async (id: string, row: V) => { + const record = { ...row, id }; + data.set(id, record); + return record; + }, upsert: async (key: keyof V, row: V) => { for (const [id, current] of data) { if (current[key] === row[key]) { diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index dc59e6c..c59657a 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -107,26 +107,38 @@ function makeOTP(args: { type Purpose = "register" | "authenticate"; function makePasskey(args: { + /** The relying party id, the domain passkeys are bound to */ + rpId: string; + /** The relying party name, shown by the authenticator */ + rpName: string; /** Stores a challenge row and returns its id, which is the challenge */ storeChallenge: (row: { purpose: Purpose }) => Promise<{ id: string }>; /** Removes a challenge row by id and returns it, atomically. Null when there is none. */ takeChallenge: (id: string) => Promise<{ purpose: Purpose } | null>; - /** Stores a credential row and returns its id, which is the credential id */ - storeCredential: (row: { publicKey: string }) => Promise<{ id: string }>; + /** Stores a credential row under the id the authenticator chose */ + storeCredential: (row: { id: string; publicKey: string }) => Promise; /** Reads a credential row by id, null when there is none */ getCredential: (id: string) => Promise<{ publicKey: string } | null>; }) { return { - beginRegistration: async () => { + /** name is what the authenticator shows for this passkey, an email or a username */ + beginRegistration: async ({ name }: { name: string }) => { const { id } = await args.storeChallenge({ purpose: "register" }); - return { challenge: id }; + + return { + challenge: id, + rp: { id: args.rpId, name: args.rpName }, + user: { name }, + }; }, finishRegistration: async ({ challenge, + credentialId, publicKey, }: { challenge: string; + credentialId: string; publicKey: string; }) => { const row = await args.takeChallenge(challenge); @@ -134,14 +146,15 @@ function makePasskey(args: { if (row === null) return fail("challenge"); if (row.purpose !== "register") return fail("challenge"); - const { id } = await args.storeCredential({ publicKey }); + await args.storeCredential({ id: credentialId, publicKey }); - return Proof.prove({ credentialId: id }); + return Proof.prove({ credentialId }); }, beginAuthentication: async () => { const { id } = await args.storeChallenge({ purpose: "authenticate" }); - return { challenge: id }; + + return { challenge: id, rpId: args.rpId }; }, finishAuthentication: async ({ @@ -210,9 +223,13 @@ const smsOtp = makeOTP({ }); const passkey = makePasskey({ + rpId: "localhost", + rpName: "Spike", storeChallenge: (row) => challengesTable.insert(row), takeChallenge: (id) => challengesTable.delete(id), - storeCredential: (row) => credentialsTable.insert(row), + storeCredential: async ({ id, publicKey }) => { + await credentialsTable.put(id, { publicKey }); + }, getCredential: (id) => credentialsTable.get(id), }); @@ -255,14 +272,17 @@ console.log("SESSION", await opaque.get(smsSessionId)); // Passkey sign-up. 1. begin, 2. browser creates a credential, 3. finish, // 4. app creates the user and links the credential, 5. session -const signUp = await passkey.beginRegistration(); +const signUp = await passkey.beginRegistration({ name: "ripley@example.com" }); +console.log("OPTIONS", signUp); +const newCredentialId = crypto.randomUUID(); const newKey = crypto.randomUUID(); const registered = await passkey.finishRegistration({ challenge: signUp.challenge, + credentialId: newCredentialId, publicKey: newKey, }); if (!(registered instanceof Proof)) throw new Error(registered.reason); -authenticator.set(registered.proven.credentialId, newKey); +authenticator.set(newCredentialId, newKey); credentialOwners.set(registered.proven.credentialId, "user-ripley"); const signUpSessionId = await opaque.make( @@ -296,14 +316,16 @@ console.log("SESSION", await opaque.get(signInSessionId)); const current = await opaque.get(signInSessionId); if (current === null) throw new Error("not signed in"); -const add = await passkey.beginRegistration(); +const add = await passkey.beginRegistration({ name: "ripley@example.com" }); +const secondCredentialId = crypto.randomUUID(); const secondKey = crypto.randomUUID(); const added = await passkey.finishRegistration({ challenge: add.challenge, + credentialId: secondCredentialId, publicKey: secondKey, }); if (!(added instanceof Proof)) throw new Error(added.reason); -authenticator.set(added.proven.credentialId, secondKey); +authenticator.set(secondCredentialId, secondKey); credentialOwners.set(added.proven.credentialId, current.userId); console.log("OWNERS", credentialOwners); From c5733fd3f3cdf515af3e16dd1ce96fb3453b7e43 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:36:42 -0700 Subject: [PATCH 15/43] Add user handle to passkeys --- spike-no-kernel.ts | 108 ++++++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 40 deletions(-) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index c59657a..99a6522 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -104,7 +104,9 @@ function makeOTP(args: { * way it maps an OTP identifier to a user. */ -type Purpose = "register" | "authenticate"; +/** A registration challenge carries the handle until the ceremony finishes */ +type Challenge = + { purpose: "register"; handle: string } | { purpose: "authenticate" }; function makePasskey(args: { /** The relying party id, the domain passkeys are bound to */ @@ -112,23 +114,39 @@ function makePasskey(args: { /** The relying party name, shown by the authenticator */ rpName: string; /** Stores a challenge row and returns its id, which is the challenge */ - storeChallenge: (row: { purpose: Purpose }) => Promise<{ id: string }>; + storeChallenge: (row: Challenge) => Promise<{ id: string }>; /** Removes a challenge row by id and returns it, atomically. Null when there is none. */ - takeChallenge: (id: string) => Promise<{ purpose: Purpose } | null>; + takeChallenge: (id: string) => Promise; /** Stores a credential row under the id the authenticator chose */ - storeCredential: (row: { id: string; publicKey: string }) => Promise; + storeCredential: (row: { + id: string; + publicKey: string; + handle: string; + }) => Promise; /** Reads a credential row by id, null when there is none */ - getCredential: (id: string) => Promise<{ publicKey: string } | null>; + getCredential: ( + id: string, + ) => Promise<{ publicKey: string; handle: string } | null>; }) { return { - /** name is what the authenticator shows for this passkey, an email or a username */ - beginRegistration: async ({ name }: { name: string }) => { - const { id } = await args.storeChallenge({ purpose: "register" }); + /** + * handle is the app's stable id for the person, the authenticator keeps it + * with the credential and returns it on authentication. name is what the + * authenticator shows, an email or a username. + */ + beginRegistration: async ({ + handle, + name, + }: { + handle: string; + name: string; + }) => { + const { id } = await args.storeChallenge({ purpose: "register", handle }); return { challenge: id, rp: { id: args.rpId, name: args.rpName }, - user: { name }, + user: { id: handle, name }, }; }, @@ -146,9 +164,13 @@ function makePasskey(args: { if (row === null) return fail("challenge"); if (row.purpose !== "register") return fail("challenge"); - await args.storeCredential({ id: credentialId, publicKey }); + await args.storeCredential({ + id: credentialId, + publicKey, + handle: row.handle, + }); - return Proof.prove({ credentialId }); + return Proof.prove({ credentialId, userHandle: row.handle }); }, beginAuthentication: async () => { @@ -161,10 +183,12 @@ function makePasskey(args: { challenge, credentialId, signature, + userHandle, }: { challenge: string; credentialId: string; signature: string; + userHandle: string; }) => { const row = await args.takeChallenge(challenge); @@ -175,8 +199,9 @@ function makePasskey(args: { if (credential === null) return fail("credential"); if (credential.publicKey !== signature) return fail("signature"); + if (credential.handle !== userHandle) return fail("handle"); - return Proof.prove({ credentialId }); + return Proof.prove({ credentialId, userHandle }); }, }; } @@ -187,14 +212,11 @@ function makePasskey(args: { const sessionsTable = createTable<{ userId: string }>(); const otpsTable = createTable<{ identifier: string; otp: string }>(); -const challengesTable = createTable<{ purpose: Purpose }>(); -const credentialsTable = createTable<{ publicKey: string }>(); +const challengesTable = createTable(); +const credentialsTable = createTable<{ publicKey: string; handle: string }>(); -// The app's own link from credential to user. The library never sees it. -const credentialOwners = new Map(); - -// The fake authenticator in the browser. Credential id to its key. -const authenticator = new Map(); +// The fake authenticator in the browser. Credential id to its key and handle. +const authenticator = new Map(); // Captures what would have been delivered, for the demo const delivered = new Map(); @@ -227,8 +249,8 @@ const passkey = makePasskey({ rpName: "Spike", storeChallenge: (row) => challengesTable.insert(row), takeChallenge: (id) => challengesTable.delete(id), - storeCredential: async ({ id, publicKey }) => { - await credentialsTable.put(id, { publicKey }); + storeCredential: async ({ id, publicKey, handle }) => { + await credentialsTable.put(id, { publicKey, handle }); }, getCredential: (id) => credentialsTable.get(id), }); @@ -270,9 +292,13 @@ const smsSessionId = await opaque.make(smsProof, async ({ identifier }) => ({ })); console.log("SESSION", await opaque.get(smsSessionId)); -// Passkey sign-up. 1. begin, 2. browser creates a credential, 3. finish, -// 4. app creates the user and links the credential, 5. session -const signUp = await passkey.beginRegistration({ name: "ripley@example.com" }); +// Passkey sign-up. 1. app creates the user, 2. begin with its id as the +// handle, 3. browser creates a credential, 4. finish, 5. session +const ripley = "user-ripley"; +const signUp = await passkey.beginRegistration({ + handle: ripley, + name: "ripley@example.com", +}); console.log("OPTIONS", signUp); const newCredentialId = crypto.randomUUID(); const newKey = crypto.randomUUID(); @@ -282,41 +308,43 @@ const registered = await passkey.finishRegistration({ publicKey: newKey, }); if (!(registered instanceof Proof)) throw new Error(registered.reason); -authenticator.set(newCredentialId, newKey); +authenticator.set(newCredentialId, { key: newKey, handle: signUp.user.id }); -credentialOwners.set(registered.proven.credentialId, "user-ripley"); const signUpSessionId = await opaque.make( registered, - async ({ credentialId }) => ({ - userId: credentialOwners.get(credentialId) ?? "", - }), + async ({ userHandle }) => ({ userId: userHandle }), ); console.log("SESSION", await opaque.get(signUpSessionId)); // Passkey sign-in. 1. begin, 2. browser signs, 3. finish, 4. session const signIn = await passkey.beginAuthentication(); -const [credentialId, key] = authenticator.entries().next().value ?? ["", ""]; +const [credentialId, stored] = authenticator.entries().next().value ?? [ + "", + { key: "", handle: "" }, +]; const authenticated = await passkey.finishAuthentication({ challenge: signIn.challenge, credentialId, - signature: key, + signature: stored.key, + userHandle: stored.handle, }); if (!(authenticated instanceof Proof)) throw new Error(authenticated.reason); const signInSessionId = await opaque.make( authenticated, - async ({ credentialId }) => ({ - userId: credentialOwners.get(credentialId) ?? "", - }), + async ({ userHandle }) => ({ userId: userHandle }), ); console.log("SESSION", await opaque.get(signInSessionId)); -// Add a passkey while signed in. The session says who, the app links, no -// new session +// Add a passkey while signed in. The session says who, its user id is the +// handle, no new session const current = await opaque.get(signInSessionId); if (current === null) throw new Error("not signed in"); -const add = await passkey.beginRegistration({ name: "ripley@example.com" }); +const add = await passkey.beginRegistration({ + handle: current.userId, + name: "ripley@example.com", +}); const secondCredentialId = crypto.randomUUID(); const secondKey = crypto.randomUUID(); const added = await passkey.finishRegistration({ @@ -325,9 +353,8 @@ const added = await passkey.finishRegistration({ publicKey: secondKey, }); if (!(added instanceof Proof)) throw new Error(added.reason); -authenticator.set(secondCredentialId, secondKey); -credentialOwners.set(added.proven.credentialId, current.userId); -console.log("OWNERS", credentialOwners); +authenticator.set(secondCredentialId, { key: secondKey, handle: add.user.id }); +console.log("ADDED", added.proven); // Failures come back with a reason const wrongOtpId = await emailOtp.send("ripley@example.com"); @@ -339,6 +366,7 @@ console.log( challenge: (await passkey.beginAuthentication()).challenge, credentialId: "not-a-credential", signature: "", + userHandle: "", }), ); From 6700d16ad20af2c0181df80a41a4528264208528 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:42:34 -0700 Subject: [PATCH 16/43] Update spike-no-kernel.ts --- spike-no-kernel.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/spike-no-kernel.ts b/spike-no-kernel.ts index 99a6522..327c728 100644 --- a/spike-no-kernel.ts +++ b/spike-no-kernel.ts @@ -39,16 +39,17 @@ function fail(reason: Reason): Failure { * Session factory */ -function makeOpaqueSession(args: { +/** Session is what the app decides a session is, a user id and whatever else it wants to keep */ +function makeOpaqueSession(args: { /** Stores a session row and returns its id */ - store: (row: Remembered) => Promise<{ id: string }>; + store: (row: Session) => Promise<{ id: string }>; /** Reads a session row by id, null when there is none */ - get: (id: string) => Promise<(Remembered & { id: string }) | null>; + get: (id: string) => Promise<(Session & { id: string }) | null>; }) { return { make: async ( proof: Proof, - resolve: (proven: T) => Promise, + resolve: (proven: T) => Promise, ) => { if (!(proof instanceof Proof)) throw new Error("not a proof"); proof.consume(); @@ -99,9 +100,9 @@ function makeOTP(args: { * Passkey factory * * Fake. No WebAuthn, the "signature" is the public key sent back as is. - * Only the shape of the two ceremonies is real. The library stores - * credentials but never who owns them, the app keeps that link, the same - * way it maps an OTP identifier to a user. + * Only the shape of the two ceremonies is real. The library stores the + * credential with the handle the app gave it and hands the handle back on + * authentication. What the handle means is the app's business. */ /** A registration challenge carries the handle until the ceremony finishes */ From 389605ad0378db0e7373141c672f1c8a0f51cc70 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:53:39 -0700 Subject: [PATCH 17/43] Move spike into package folder --- spike-helpers.ts => packages/spike/spike-helpers.ts | 0 spike-no-kernel.ts => packages/spike/spike-no-kernel.ts | 0 spike.ts => packages/spike/spike.ts | 0 tsconfig.json => packages/spike/tsconfig.json | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename spike-helpers.ts => packages/spike/spike-helpers.ts (100%) rename spike-no-kernel.ts => packages/spike/spike-no-kernel.ts (100%) rename spike.ts => packages/spike/spike.ts (100%) rename tsconfig.json => packages/spike/tsconfig.json (100%) diff --git a/spike-helpers.ts b/packages/spike/spike-helpers.ts similarity index 100% rename from spike-helpers.ts rename to packages/spike/spike-helpers.ts diff --git a/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts similarity index 100% rename from spike-no-kernel.ts rename to packages/spike/spike-no-kernel.ts diff --git a/spike.ts b/packages/spike/spike.ts similarity index 100% rename from spike.ts rename to packages/spike/spike.ts diff --git a/tsconfig.json b/packages/spike/tsconfig.json similarity index 100% rename from tsconfig.json rename to packages/spike/tsconfig.json From c75b70d49a1e73306e052c66d01c6e6c1f587eab Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 18:56:17 -0700 Subject: [PATCH 18/43] Add spike package files --- packages/spike/package.json | 8 ++++++++ packages/spike/tsconfig.json | 9 +++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 packages/spike/package.json diff --git a/packages/spike/package.json b/packages/spike/package.json new file mode 100644 index 0000000..a531974 --- /dev/null +++ b/packages/spike/package.json @@ -0,0 +1,8 @@ +{ + "name": "@repo/spike", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc" + } +} diff --git a/packages/spike/tsconfig.json b/packages/spike/tsconfig.json index 9a049ef..89ca6a2 100644 --- a/packages/spike/tsconfig.json +++ b/packages/spike/tsconfig.json @@ -1,5 +1,11 @@ { "compilerOptions": { + /* Environment */ + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + /* Base */ "moduleDetection": "force", "verbatimModuleSyntax": true, @@ -13,6 +19,5 @@ "noImplicitReturns": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true - }, - "include": ["spike.ts", "spike-no-kernel.ts", "spike-helpers.ts"] + } } From 31f54d8fa36f101c3f9094bdfbd5bdf9ad8bbad3 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 19:03:23 -0700 Subject: [PATCH 19/43] Split spike into modules --- packages/spike/spike-no-kernel.ts | 232 +++------------------------ packages/spike/src/index.ts | 6 + packages/spike/src/opaque-session.ts | 25 +++ packages/spike/src/otp.ts | 30 ++++ packages/spike/src/passkey.ts | 110 +++++++++++++ packages/spike/src/proof.ts | 37 +++++ 6 files changed, 227 insertions(+), 213 deletions(-) create mode 100644 packages/spike/src/index.ts create mode 100644 packages/spike/src/opaque-session.ts create mode 100644 packages/spike/src/otp.ts create mode 100644 packages/spike/src/passkey.ts create mode 100644 packages/spike/src/proof.ts diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 327c728..7e1a1aa 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -1,212 +1,13 @@ +import { + isProof, + makeOpaqueSession, + makeOTP, + makePasskey, + type Challenge, + type Proof, +} from "./src/index"; import { createTable } from "./spike-helpers"; -/** - * Proof - */ - -/** - * What a strategy proved. Only a strategy can construct one, and a session - * cannot be made without one. In a package, only the type is exported, so - * the factory is reachable from strategies alone. - */ -class Proof { - readonly proven: T; - private used = false; - - private constructor(proven: T) { - this.proven = proven; - } - - static prove(proven: T) { - return new Proof(proven); - } - - /** A proof is spent by the one call that uses it. A second call throws. */ - consume() { - if (this.used) throw new Error("proof already used"); - this.used = true; - } -} - -/** An expected failure the app branches on. The reason names what did not hold. */ -type Failure = { reason: Reason }; - -function fail(reason: Reason): Failure { - return { reason }; -} - -/** - * Session factory - */ - -/** Session is what the app decides a session is, a user id and whatever else it wants to keep */ -function makeOpaqueSession(args: { - /** Stores a session row and returns its id */ - store: (row: Session) => Promise<{ id: string }>; - /** Reads a session row by id, null when there is none */ - get: (id: string) => Promise<(Session & { id: string }) | null>; -}) { - return { - make: async ( - proof: Proof, - resolve: (proven: T) => Promise, - ) => { - if (!(proof instanceof Proof)) throw new Error("not a proof"); - proof.consume(); - - const { id } = await args.store(await resolve(proof.proven)); - - return id; - }, - - get: (id: string) => args.get(id), - }; -} - -/** - * OTP factory - */ - -function makeOTP(args: { - /** Stores an otp row and returns its id */ - store: (row: { identifier: string; otp: string }) => Promise<{ id: string }>; - /** Removes an otp row by id and returns it, atomically. Null when there is none. */ - take: (id: string) => Promise<{ identifier: string; otp: string } | null>; - /** Delivers the otp to the identifier, an email address or a phone number */ - send: (identifier: string, otp: string) => Promise; -}) { - return { - send: async (identifier: string) => { - const otp = crypto.randomUUID(); - const { id } = await args.store({ identifier, otp }); - - await args.send(identifier, otp); - - return id; - }, - - verify: async ({ id, otp }: { id: string; otp: string }) => { - const row = await args.take(id); - - if (row === null) return fail("unknown"); - if (row.otp !== otp) return fail("mismatch"); - - return Proof.prove({ identifier: row.identifier }); - }, - }; -} - -/** - * Passkey factory - * - * Fake. No WebAuthn, the "signature" is the public key sent back as is. - * Only the shape of the two ceremonies is real. The library stores the - * credential with the handle the app gave it and hands the handle back on - * authentication. What the handle means is the app's business. - */ - -/** A registration challenge carries the handle until the ceremony finishes */ -type Challenge = - { purpose: "register"; handle: string } | { purpose: "authenticate" }; - -function makePasskey(args: { - /** The relying party id, the domain passkeys are bound to */ - rpId: string; - /** The relying party name, shown by the authenticator */ - rpName: string; - /** Stores a challenge row and returns its id, which is the challenge */ - storeChallenge: (row: Challenge) => Promise<{ id: string }>; - /** Removes a challenge row by id and returns it, atomically. Null when there is none. */ - takeChallenge: (id: string) => Promise; - /** Stores a credential row under the id the authenticator chose */ - storeCredential: (row: { - id: string; - publicKey: string; - handle: string; - }) => Promise; - /** Reads a credential row by id, null when there is none */ - getCredential: ( - id: string, - ) => Promise<{ publicKey: string; handle: string } | null>; -}) { - return { - /** - * handle is the app's stable id for the person, the authenticator keeps it - * with the credential and returns it on authentication. name is what the - * authenticator shows, an email or a username. - */ - beginRegistration: async ({ - handle, - name, - }: { - handle: string; - name: string; - }) => { - const { id } = await args.storeChallenge({ purpose: "register", handle }); - - return { - challenge: id, - rp: { id: args.rpId, name: args.rpName }, - user: { id: handle, name }, - }; - }, - - finishRegistration: async ({ - challenge, - credentialId, - publicKey, - }: { - challenge: string; - credentialId: string; - publicKey: string; - }) => { - const row = await args.takeChallenge(challenge); - - if (row === null) return fail("challenge"); - if (row.purpose !== "register") return fail("challenge"); - - await args.storeCredential({ - id: credentialId, - publicKey, - handle: row.handle, - }); - - return Proof.prove({ credentialId, userHandle: row.handle }); - }, - - beginAuthentication: async () => { - const { id } = await args.storeChallenge({ purpose: "authenticate" }); - - return { challenge: id, rpId: args.rpId }; - }, - - finishAuthentication: async ({ - challenge, - credentialId, - signature, - userHandle, - }: { - challenge: string; - credentialId: string; - signature: string; - userHandle: string; - }) => { - const row = await args.takeChallenge(challenge); - - if (row === null) return fail("challenge"); - if (row.purpose !== "authenticate") return fail("challenge"); - - const credential = await args.getCredential(credentialId); - - if (credential === null) return fail("credential"); - if (credential.publicKey !== signature) return fail("signature"); - if (credential.handle !== userHandle) return fail("handle"); - - return Proof.prove({ credentialId, userHandle }); - }, - }; -} - /** * App */ @@ -269,7 +70,7 @@ const emailProof = await emailOtp.verify({ id: emailOtpId, otp: delivered.get("ripley@example.com") ?? "", }); -if (!(emailProof instanceof Proof)) throw new Error(emailProof.reason); +if (!isProof(emailProof)) throw new Error(emailProof.reason); const emailSessionId = await opaque.make( emailProof, @@ -286,7 +87,7 @@ const smsProof = await smsOtp.verify({ id: smsOtpId, otp: delivered.get("+15555550100") ?? "", }); -if (!(smsProof instanceof Proof)) throw new Error(smsProof.reason); +if (!isProof(smsProof)) throw new Error(smsProof.reason); const smsSessionId = await opaque.make(smsProof, async ({ identifier }) => ({ userId: `user-for-${identifier}`, @@ -308,7 +109,7 @@ const registered = await passkey.finishRegistration({ credentialId: newCredentialId, publicKey: newKey, }); -if (!(registered instanceof Proof)) throw new Error(registered.reason); +if (!isProof(registered)) throw new Error(registered.reason); authenticator.set(newCredentialId, { key: newKey, handle: signUp.user.id }); const signUpSessionId = await opaque.make( @@ -329,7 +130,7 @@ const authenticated = await passkey.finishAuthentication({ signature: stored.key, userHandle: stored.handle, }); -if (!(authenticated instanceof Proof)) throw new Error(authenticated.reason); +if (!isProof(authenticated)) throw new Error(authenticated.reason); const signInSessionId = await opaque.make( authenticated, @@ -353,7 +154,7 @@ const added = await passkey.finishRegistration({ credentialId: secondCredentialId, publicKey: secondKey, }); -if (!(added instanceof Proof)) throw new Error(added.reason); +if (!isProof(added)) throw new Error(added.reason); authenticator.set(secondCredentialId, { key: secondKey, handle: add.user.id }); console.log("ADDED", added.proven); @@ -378,8 +179,13 @@ try { console.log("REUSE", error instanceof Error ? error.message : error); } -// The guard. Never called, it exists to show what does not compile. +// The guards. Never called, they exist to show what does not compile. export function withoutProof(userId: string) { // @ts-expect-error a session cannot be made without a proof return opaque.make({ userId }, async () => ({ userId })); } + +export function mintProof() { + // @ts-expect-error the app cannot mint a proof, only the type is exported + return Proof.prove({ userId: "anyone" }); +} diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts new file mode 100644 index 0000000..2bb6c1b --- /dev/null +++ b/packages/spike/src/index.ts @@ -0,0 +1,6 @@ +export type { Proof, Failure } from "./proof"; +export { isProof } from "./proof"; +export { makeOpaqueSession } from "./opaque-session"; +export { makeOTP } from "./otp"; +export { makePasskey } from "./passkey"; +export type { Challenge } from "./passkey"; diff --git a/packages/spike/src/opaque-session.ts b/packages/spike/src/opaque-session.ts new file mode 100644 index 0000000..6fe66b3 --- /dev/null +++ b/packages/spike/src/opaque-session.ts @@ -0,0 +1,25 @@ +import { Proof } from "./proof"; + +/** Session is what the app decides a session is, a user id and whatever else it wants to keep */ +export function makeOpaqueSession(args: { + /** Stores a session row and returns its id */ + store: (row: Session) => Promise<{ id: string }>; + /** Reads a session row by id, null when there is none */ + get: (id: string) => Promise<(Session & { id: string }) | null>; +}) { + return { + make: async ( + proof: Proof, + resolve: (proven: T) => Promise, + ) => { + if (!(proof instanceof Proof)) throw new Error("not a proof"); + proof.consume(); + + const { id } = await args.store(await resolve(proof.proven)); + + return id; + }, + + get: (id: string) => args.get(id), + }; +} diff --git a/packages/spike/src/otp.ts b/packages/spike/src/otp.ts new file mode 100644 index 0000000..207bf05 --- /dev/null +++ b/packages/spike/src/otp.ts @@ -0,0 +1,30 @@ +import { Proof, fail } from "./proof"; + +export function makeOTP(args: { + /** Stores an otp row and returns its id */ + store: (row: { identifier: string; otp: string }) => Promise<{ id: string }>; + /** Removes an otp row by id and returns it, atomically. Null when there is none. */ + take: (id: string) => Promise<{ identifier: string; otp: string } | null>; + /** Delivers the otp to the identifier, an email address or a phone number */ + send: (identifier: string, otp: string) => Promise; +}) { + return { + send: async (identifier: string) => { + const otp = crypto.randomUUID(); + const { id } = await args.store({ identifier, otp }); + + await args.send(identifier, otp); + + return id; + }, + + verify: async ({ id, otp }: { id: string; otp: string }) => { + const row = await args.take(id); + + if (row === null) return fail("unknown"); + if (row.otp !== otp) return fail("mismatch"); + + return Proof.prove({ identifier: row.identifier }); + }, + }; +} diff --git a/packages/spike/src/passkey.ts b/packages/spike/src/passkey.ts new file mode 100644 index 0000000..93f9cc4 --- /dev/null +++ b/packages/spike/src/passkey.ts @@ -0,0 +1,110 @@ +import { Proof, fail } from "./proof"; + +/** + * Fake. No WebAuthn, the "signature" is the public key sent back as is. + * Only the shape of the two ceremonies is real. The library stores the + * credential with the handle the app gave it and hands the handle back on + * authentication. What the handle means is the app's business. + */ + +/** A registration challenge carries the handle until the ceremony finishes */ +export type Challenge = + { purpose: "register"; handle: string } | { purpose: "authenticate" }; + +export function makePasskey(args: { + /** The relying party id, the domain passkeys are bound to */ + rpId: string; + /** The relying party name, shown by the authenticator */ + rpName: string; + /** Stores a challenge row and returns its id, which is the challenge */ + storeChallenge: (row: Challenge) => Promise<{ id: string }>; + /** Removes a challenge row by id and returns it, atomically. Null when there is none. */ + takeChallenge: (id: string) => Promise; + /** Stores a credential row under the id the authenticator chose */ + storeCredential: (row: { + id: string; + publicKey: string; + handle: string; + }) => Promise; + /** Reads a credential row by id, null when there is none */ + getCredential: ( + id: string, + ) => Promise<{ publicKey: string; handle: string } | null>; +}) { + return { + /** + * handle is the app's stable id for the person, the authenticator keeps it + * with the credential and returns it on authentication. name is what the + * authenticator shows, an email or a username. + */ + beginRegistration: async ({ + handle, + name, + }: { + handle: string; + name: string; + }) => { + const { id } = await args.storeChallenge({ purpose: "register", handle }); + + return { + challenge: id, + rp: { id: args.rpId, name: args.rpName }, + user: { id: handle, name }, + }; + }, + + finishRegistration: async ({ + challenge, + credentialId, + publicKey, + }: { + challenge: string; + credentialId: string; + publicKey: string; + }) => { + const row = await args.takeChallenge(challenge); + + if (row === null) return fail("challenge"); + if (row.purpose !== "register") return fail("challenge"); + + await args.storeCredential({ + id: credentialId, + publicKey, + handle: row.handle, + }); + + return Proof.prove({ credentialId, userHandle: row.handle }); + }, + + beginAuthentication: async () => { + const { id } = await args.storeChallenge({ purpose: "authenticate" }); + + return { challenge: id, rpId: args.rpId }; + }, + + finishAuthentication: async ({ + challenge, + credentialId, + signature, + userHandle, + }: { + challenge: string; + credentialId: string; + signature: string; + userHandle: string; + }) => { + const row = await args.takeChallenge(challenge); + + if (row === null) return fail("challenge"); + if (row.purpose !== "authenticate") return fail("challenge"); + + const credential = await args.getCredential(credentialId); + + if (credential === null) return fail("credential"); + if (credential.publicKey !== signature) return fail("signature"); + if (credential.handle !== userHandle) return fail("handle"); + + return Proof.prove({ credentialId, userHandle }); + }, + }; +} diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts new file mode 100644 index 0000000..76b2b50 --- /dev/null +++ b/packages/spike/src/proof.ts @@ -0,0 +1,37 @@ +/** + * What a strategy proved. Only a strategy can construct one, and a session + * cannot be made without one. The public entry exports the type only, so + * the app can name a proof but never make one. + */ +export class Proof { + readonly proven: T; + private used = false; + + private constructor(proven: T) { + this.proven = proven; + } + + static prove(proven: T) { + return new Proof(proven); + } + + /** A proof is spent by the one call that uses it. A second call throws. */ + consume() { + if (this.used) throw new Error("proof already used"); + this.used = true; + } +} + +/** An expected failure the app branches on. The reason names what did not hold. */ +export type Failure = { reason: Reason }; + +export function fail(reason: Reason): Failure { + return { reason }; +} + +/** Narrows a strategy's result to the proof. The app's way to tell them apart. */ +export function isProof( + value: Proof | Failure, +): value is Proof { + return value instanceof Proof; +} From 94eb2d27850b72fed368b799a6473c30967948f5 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 19:26:45 -0700 Subject: [PATCH 20/43] Add signed session and contract --- packages/spike/spike-no-kernel.ts | 21 +++++++ packages/spike/src/index.ts | 2 + packages/spike/src/opaque-session.ts | 14 ++--- packages/spike/src/proof.ts | 15 +++-- packages/spike/src/session.ts | 15 +++++ packages/spike/src/signed-session.ts | 88 ++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 packages/spike/src/session.ts create mode 100644 packages/spike/src/signed-session.ts diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 7e1a1aa..e373800 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -3,6 +3,7 @@ import { makeOpaqueSession, makeOTP, makePasskey, + makeSignedSession, type Challenge, type Proof, } from "./src/index"; @@ -158,6 +159,26 @@ if (!isProof(added)) throw new Error(added.reason); authenticator.set(secondCredentialId, { key: secondKey, handle: add.user.id }); console.log("ADDED", added.proven); +// Signed session. Same three steps, no table. The token carries the session +const signed = makeSignedSession<{ userId: string }>({ + secret: "spike-secret", + ttl: 60 * 60 * 1000, +}); + +const signedOtpId = await emailOtp.send("ripley@example.com"); +const signedProof = await emailOtp.verify({ + id: signedOtpId, + otp: delivered.get("ripley@example.com") ?? "", +}); +if (!isProof(signedProof)) throw new Error(signedProof.reason); + +const token = await signed.make(signedProof, async ({ identifier }) => ({ + userId: `user-for-${identifier}`, +})); +console.log("TOKEN", token); +console.log("SIGNED SESSION", await signed.get(token)); +console.log("TAMPERED", await signed.get(`${token.slice(0, -2)}xx`)); + // Failures come back with a reason const wrongOtpId = await emailOtp.send("ripley@example.com"); console.log("WRONG", await emailOtp.verify({ id: wrongOtpId, otp: "nope" })); diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts index 2bb6c1b..41b9787 100644 --- a/packages/spike/src/index.ts +++ b/packages/spike/src/index.ts @@ -1,6 +1,8 @@ export type { Proof, Failure } from "./proof"; export { isProof } from "./proof"; +export type { SessionContract } from "./session"; export { makeOpaqueSession } from "./opaque-session"; +export { makeSignedSession } from "./signed-session"; export { makeOTP } from "./otp"; export { makePasskey } from "./passkey"; export type { Challenge } from "./passkey"; diff --git a/packages/spike/src/opaque-session.ts b/packages/spike/src/opaque-session.ts index 6fe66b3..0aedca6 100644 --- a/packages/spike/src/opaque-session.ts +++ b/packages/spike/src/opaque-session.ts @@ -1,25 +1,23 @@ import { Proof } from "./proof"; +import type { SessionContract } from "./session"; -/** Session is what the app decides a session is, a user id and whatever else it wants to keep */ +/** The token is the row id. Ending a session is deleting the row. */ export function makeOpaqueSession(args: { /** Stores a session row and returns its id */ store: (row: Session) => Promise<{ id: string }>; /** Reads a session row by id, null when there is none */ - get: (id: string) => Promise<(Session & { id: string }) | null>; + get: (id: string) => Promise; }) { return { make: async ( proof: Proof, resolve: (proven: T) => Promise, ) => { - if (!(proof instanceof Proof)) throw new Error("not a proof"); - proof.consume(); - - const { id } = await args.store(await resolve(proof.proven)); + const { id } = await args.store(await resolve(Proof.spend(proof))); return id; }, - get: (id: string) => args.get(id), - }; + get: (token: string) => args.get(token), + } satisfies SessionContract; } diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts index 76b2b50..fbc6ef4 100644 --- a/packages/spike/src/proof.ts +++ b/packages/spike/src/proof.ts @@ -15,10 +15,17 @@ export class Proof { return new Proof(proven); } - /** A proof is spent by the one call that uses it. A second call throws. */ - consume() { - if (this.used) throw new Error("proof already used"); - this.used = true; + /** + * Spends a proof and returns what it proved. Every session implementation + * calls this first, so the rule lives here and not in each of them. A + * second call with the same proof throws. + */ + static spend(proof: Proof): T { + if (!(proof instanceof Proof)) throw new Error("not a proof"); + if (proof.used) throw new Error("proof already used"); + proof.used = true; + + return proof.proven; } } diff --git a/packages/spike/src/session.ts b/packages/spike/src/session.ts new file mode 100644 index 0000000..f45684d --- /dev/null +++ b/packages/spike/src/session.ts @@ -0,0 +1,15 @@ +import type { Proof } from "./proof"; + +/** + * What every session implementation provides. make spends a proof and + * returns a token, get turns a token back into the session or null. Anything + * else a mechanism can do, such as ending a session, is its own method beside + * these two. Session is what the app decides a session is. + */ +export type SessionContract = { + make: ( + proof: Proof, + resolve: (proven: T) => Promise, + ) => Promise; + get: (token: string) => Promise; +}; diff --git a/packages/spike/src/signed-session.ts b/packages/spike/src/signed-session.ts new file mode 100644 index 0000000..8aa6ef2 --- /dev/null +++ b/packages/spike/src/signed-session.ts @@ -0,0 +1,88 @@ +import { Proof } from "./proof"; +import type { SessionContract } from "./session"; + +/** + * Stateless. The session travels inside the token, signed so it cannot be + * altered. Nothing is stored, so there is nothing to end. A token is valid + * until it expires, which is why ttl is not optional here. + */ +export function makeSignedSession(args: { + /** HMAC secret. Anyone holding it can mint a session. */ + secret: string; + /** Lifetime of a token in ms */ + ttl: number; +}) { + const key = crypto.subtle.importKey( + "raw", + new TextEncoder().encode(args.secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); + + return { + make: async ( + proof: Proof, + resolve: (proven: T) => Promise, + ) => { + const session = await resolve(Proof.spend(proof)); + const payload = encode( + JSON.stringify({ session, exp: Date.now() + args.ttl }), + ); + const signature = await crypto.subtle.sign( + "HMAC", + await key, + bytes(payload), + ); + + return `${payload}.${encode(signature)}`; + }, + + get: async (token: string) => { + const [payload, signature, ...rest] = token.split("."); + if (payload === undefined || signature === undefined || rest.length > 0) { + return null; + } + + const valid = await crypto.subtle.verify( + "HMAC", + await key, + decode(signature), + bytes(payload), + ); + if (!valid) return null; + + // Safe to trust the shape, the signature proves this process wrote it + const { session, exp } = JSON.parse(text(decode(payload))) as { + session: Session; + exp: number; + }; + if (exp < Date.now()) return null; + + return session; + }, + } satisfies SessionContract; +} + +function bytes(value: string) { + return new TextEncoder().encode(value); +} + +function text(value: Uint8Array) { + return new TextDecoder().decode(value); +} + +function encode(value: string | ArrayBuffer) { + const raw = typeof value === "string" ? bytes(value) : new Uint8Array(value); + + return btoa(String.fromCharCode(...raw)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +function decode(value: string) { + const padded = value.replaceAll("-", "+").replaceAll("_", "/"); + + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); +} From 321acf798443d2ef639e9552e1d152ea5587b6d7 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 19:31:59 -0700 Subject: [PATCH 21/43] Group sessions and strategies --- packages/spike/src/index.ts | 12 ++++++------ .../spike/src/{session.ts => sessions/contract.ts} | 2 +- .../src/{opaque-session.ts => sessions/opaque.ts} | 4 ++-- .../src/{signed-session.ts => sessions/signed.ts} | 4 ++-- packages/spike/src/{ => strategies}/otp.ts | 2 +- packages/spike/src/{ => strategies}/passkey.ts | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) rename packages/spike/src/{session.ts => sessions/contract.ts} (92%) rename packages/spike/src/{opaque-session.ts => sessions/opaque.ts} (88%) rename packages/spike/src/{signed-session.ts => sessions/signed.ts} (96%) rename packages/spike/src/{ => strategies}/otp.ts (96%) rename packages/spike/src/{ => strategies}/passkey.ts (98%) diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts index 41b9787..ec373c7 100644 --- a/packages/spike/src/index.ts +++ b/packages/spike/src/index.ts @@ -1,8 +1,8 @@ export type { Proof, Failure } from "./proof"; export { isProof } from "./proof"; -export type { SessionContract } from "./session"; -export { makeOpaqueSession } from "./opaque-session"; -export { makeSignedSession } from "./signed-session"; -export { makeOTP } from "./otp"; -export { makePasskey } from "./passkey"; -export type { Challenge } from "./passkey"; +export type { SessionContract } from "./sessions/contract"; +export { makeOpaqueSession } from "./sessions/opaque"; +export { makeSignedSession } from "./sessions/signed"; +export { makeOTP } from "./strategies/otp"; +export { makePasskey } from "./strategies/passkey"; +export type { Challenge } from "./strategies/passkey"; diff --git a/packages/spike/src/session.ts b/packages/spike/src/sessions/contract.ts similarity index 92% rename from packages/spike/src/session.ts rename to packages/spike/src/sessions/contract.ts index f45684d..53b6e0a 100644 --- a/packages/spike/src/session.ts +++ b/packages/spike/src/sessions/contract.ts @@ -1,4 +1,4 @@ -import type { Proof } from "./proof"; +import type { Proof } from "../proof"; /** * What every session implementation provides. make spends a proof and diff --git a/packages/spike/src/opaque-session.ts b/packages/spike/src/sessions/opaque.ts similarity index 88% rename from packages/spike/src/opaque-session.ts rename to packages/spike/src/sessions/opaque.ts index 0aedca6..1738eb6 100644 --- a/packages/spike/src/opaque-session.ts +++ b/packages/spike/src/sessions/opaque.ts @@ -1,5 +1,5 @@ -import { Proof } from "./proof"; -import type { SessionContract } from "./session"; +import { Proof } from "../proof"; +import type { SessionContract } from "./contract"; /** The token is the row id. Ending a session is deleting the row. */ export function makeOpaqueSession(args: { diff --git a/packages/spike/src/signed-session.ts b/packages/spike/src/sessions/signed.ts similarity index 96% rename from packages/spike/src/signed-session.ts rename to packages/spike/src/sessions/signed.ts index 8aa6ef2..0eebf05 100644 --- a/packages/spike/src/signed-session.ts +++ b/packages/spike/src/sessions/signed.ts @@ -1,5 +1,5 @@ -import { Proof } from "./proof"; -import type { SessionContract } from "./session"; +import { Proof } from "../proof"; +import type { SessionContract } from "./contract"; /** * Stateless. The session travels inside the token, signed so it cannot be diff --git a/packages/spike/src/otp.ts b/packages/spike/src/strategies/otp.ts similarity index 96% rename from packages/spike/src/otp.ts rename to packages/spike/src/strategies/otp.ts index 207bf05..456fd33 100644 --- a/packages/spike/src/otp.ts +++ b/packages/spike/src/strategies/otp.ts @@ -1,4 +1,4 @@ -import { Proof, fail } from "./proof"; +import { Proof, fail } from "../proof"; export function makeOTP(args: { /** Stores an otp row and returns its id */ diff --git a/packages/spike/src/passkey.ts b/packages/spike/src/strategies/passkey.ts similarity index 98% rename from packages/spike/src/passkey.ts rename to packages/spike/src/strategies/passkey.ts index 93f9cc4..48e9ee9 100644 --- a/packages/spike/src/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -1,4 +1,4 @@ -import { Proof, fail } from "./proof"; +import { Proof, fail } from "../proof"; /** * Fake. No WebAuthn, the "signature" is the public key sent back as is. From 429bb92a8a3e4d3020dda2961fe93b26ae7f6aec Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 19:46:13 -0700 Subject: [PATCH 22/43] Add spike decisions --- packages/spike/README.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/spike/README.md diff --git a/packages/spike/README.md b/packages/spike/README.md new file mode 100644 index 0000000..dfb33ac --- /dev/null +++ b/packages/spike/README.md @@ -0,0 +1,6 @@ +# Spike + +## Decisions + +- An OTP is verified against the request that asked for it, not the address. The requesting client holds a random handle from `send`, and the code alone is useless without it. Signing in on another device is a magic link's job. +- The library generates every string it hands a client. OTP handles, passkey challenges, and session tokens are random columns the library fills, never the app's ids. The app keys its tables however it likes. From 7b0e8b9a49630a484a499db1fef8eb6e51f14536 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 26 Sep 2026 20:07:03 -0700 Subject: [PATCH 23/43] Pin Bun and freeze lockfile --- .github/workflows/ci.yml | 8 -------- bun.lock | 5 +++++ package.json | 3 ++- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93cc892..2a8fb43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/bun.lock b/bun.lock index a0b993e..bb846fb 100644 --- a/bun.lock +++ b/bun.lock @@ -242,6 +242,9 @@ "next", ], }, + "packages/spike": { + "name": "@repo/spike", + }, }, "trustedDependencies": [ "sharp", @@ -509,6 +512,8 @@ "@repo/shared-webauthn": ["@repo/shared-webauthn@workspace:examples/shared/webauthn"], + "@repo/spike": ["@repo/spike@workspace:packages/spike"], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], diff --git a/package.json b/package.json index 09abcb3..94c7b40 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@repo/monorepo", "private": true, "type": "module", + "packageManager": "bun@1.4.2", "workspaces": [ "packages/*", "examples/*/*" @@ -14,7 +15,7 @@ "test:run": "vitest run", "test:watch": "vitest", "typecheck": "bun run --filter '*' typecheck", - "check": "bun run format:check && bun run lint:check && bun run test:run && bun run typecheck", + "check": "bun install --frozen-lockfile && bun run format:check && bun run lint:check && bun run test:run && bun run typecheck", "outdated": "bun outdated --recursive", "update": "bun update --recursive", "update:latest": "bun update --latest --recursive", From b74d46d85a98f3ce64b657625e460c1fb4c8daf5 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 07:26:31 -0700 Subject: [PATCH 24/43] Library generates client strings --- packages/spike/README.md | 22 +++++++++- packages/spike/spike-no-kernel.ts | 54 +++++++++++++++--------- packages/spike/src/sessions/opaque.ts | 17 +++++--- packages/spike/src/strategies/otp.ts | 21 +++++---- packages/spike/src/strategies/passkey.ts | 36 ++++++++-------- 5 files changed, 96 insertions(+), 54 deletions(-) diff --git a/packages/spike/README.md b/packages/spike/README.md index dfb33ac..21de026 100644 --- a/packages/spike/README.md +++ b/packages/spike/README.md @@ -2,5 +2,23 @@ ## Decisions -- An OTP is verified against the request that asked for it, not the address. The requesting client holds a random handle from `send`, and the code alone is useless without it. Signing in on another device is a magic link's job. -- The library generates every string it hands a client. OTP handles, passkey challenges, and session tokens are random columns the library fills, never the app's ids. The app keys its tables however it likes. +- An OTP is verified against the request that asked for it, not the identifier. The requesting client holds a random ticket from `send`, and the OTP alone is useless without it. Signing in on another device is a magic link's job. +- The library generates every string it hands a client. OTP tickets, passkey challenges, and session tokens are random columns the library fills, never the app's ids. The app keys its tables however it likes. +- A different trade-off is a different factory. An OTP verified against the identifier, with nothing for the client to hold, would sit beside the ticket one rather than change it. + +## OTP lookup options + +The identifier is the email address or phone number the OTP is sent to. + +| | Ticket | Identifier, one pending | Identifier, many pending | +| --- | --- | --- | --- | +| Verify looks up by | the ticket | the identifier | the identifier and the OTP together | +| Client holds between send and verify | the ticket | nothing | nothing | +| Typing the OTP on another device | does not work | works | works | +| A second request for the same identifier | stands beside the first | replaces the first | stands beside the first | +| A guess is tested against | one OTP | one OTP | every pending OTP for that identifier | +| Wrong attempts are counted on | the request row | the identifier row | a separate counter per identifier | +| Someone sees the OTP mid sign-in | useless without the ticket | usable with the identifier | usable with the identifier | +| A stranger requests an OTP for your identifier | yours is untouched | yours stops working | one more OTP that would work | + +Ticket is the only option where an OTP is bound to the client that asked and where a stranger's request changes nothing for you. Identifier with one pending is the simplest for the app and the weakest against interference. Identifier with many pending has the costs of both and should not be built. diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index e373800..59d96ae 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -25,13 +25,17 @@ const authenticator = new Map(); const delivered = new Map(); const opaque = makeOpaqueSession<{ userId: string }>({ - store: (row) => sessionsTable.insert(row), - get: (id) => sessionsTable.get(id), + store: async (token, row) => { + await sessionsTable.put(token, row); + }, + get: (token) => sessionsTable.get(token), }); const emailOtp = makeOTP({ - store: (row) => otpsTable.insert(row), - take: (id) => otpsTable.delete(id), + store: async (ticket, row) => { + await otpsTable.put(ticket, row); + }, + take: (ticket) => otpsTable.delete(ticket), send: async (identifier, otp) => { console.log("email to:", identifier, "otp:", otp); delivered.set(identifier, otp); @@ -39,8 +43,10 @@ const emailOtp = makeOTP({ }); const smsOtp = makeOTP({ - store: (row) => otpsTable.insert(row), - take: (id) => otpsTable.delete(id), + store: async (ticket, row) => { + await otpsTable.put(ticket, row); + }, + take: (ticket) => otpsTable.delete(ticket), send: async (identifier, otp) => { console.log("sms to:", identifier, "otp:", otp); delivered.set(identifier, otp); @@ -50,12 +56,14 @@ const smsOtp = makeOTP({ const passkey = makePasskey({ rpId: "localhost", rpName: "Spike", - storeChallenge: (row) => challengesTable.insert(row), - takeChallenge: (id) => challengesTable.delete(id), - storeCredential: async ({ id, publicKey, handle }) => { - await credentialsTable.put(id, { publicKey, handle }); + storeChallenge: async (challenge, row) => { + await challengesTable.put(challenge, row); + }, + takeChallenge: (challenge) => challengesTable.delete(challenge), + storeCredential: async (credentialId, row) => { + await credentialsTable.put(credentialId, row); }, - getCredential: (id) => credentialsTable.get(id), + getCredential: (credentialId) => credentialsTable.get(credentialId), }); // @@ -65,10 +73,10 @@ const passkey = makePasskey({ console.log("-".repeat(80)); // Email. 1. request, 2. verify, 3. session -const emailOtpId = await emailOtp.send("ripley@example.com"); +const emailTicket = await emailOtp.send("ripley@example.com"); const emailProof = await emailOtp.verify({ - id: emailOtpId, + ticket: emailTicket, otp: delivered.get("ripley@example.com") ?? "", }); if (!isProof(emailProof)) throw new Error(emailProof.reason); @@ -82,10 +90,10 @@ const emailSessionId = await opaque.make( console.log("SESSION", await opaque.get(emailSessionId)); // SMS. Same three steps, other instance -const smsOtpId = await smsOtp.send("+15555550100"); +const smsTicket = await smsOtp.send("+15555550100"); const smsProof = await smsOtp.verify({ - id: smsOtpId, + ticket: smsTicket, otp: delivered.get("+15555550100") ?? "", }); if (!isProof(smsProof)) throw new Error(smsProof.reason); @@ -165,9 +173,9 @@ const signed = makeSignedSession<{ userId: string }>({ ttl: 60 * 60 * 1000, }); -const signedOtpId = await emailOtp.send("ripley@example.com"); +const signedTicket = await emailOtp.send("ripley@example.com"); const signedProof = await emailOtp.verify({ - id: signedOtpId, + ticket: signedTicket, otp: delivered.get("ripley@example.com") ?? "", }); if (!isProof(signedProof)) throw new Error(signedProof.reason); @@ -180,9 +188,15 @@ console.log("SIGNED SESSION", await signed.get(token)); console.log("TAMPERED", await signed.get(`${token.slice(0, -2)}xx`)); // Failures come back with a reason -const wrongOtpId = await emailOtp.send("ripley@example.com"); -console.log("WRONG", await emailOtp.verify({ id: wrongOtpId, otp: "nope" })); -console.log("USED", await emailOtp.verify({ id: wrongOtpId, otp: "nope" })); +const wrongTicket = await emailOtp.send("ripley@example.com"); +console.log( + "WRONG", + await emailOtp.verify({ ticket: wrongTicket, otp: "nope" }), +); +console.log( + "USED", + await emailOtp.verify({ ticket: wrongTicket, otp: "nope" }), +); console.log( "STRANGER", await passkey.finishAuthentication({ diff --git a/packages/spike/src/sessions/opaque.ts b/packages/spike/src/sessions/opaque.ts index 1738eb6..772a813 100644 --- a/packages/spike/src/sessions/opaque.ts +++ b/packages/spike/src/sessions/opaque.ts @@ -1,21 +1,24 @@ import { Proof } from "../proof"; import type { SessionContract } from "./contract"; -/** The token is the row id. Ending a session is deleting the row. */ +/** The token is a random string the row is stored under. Ending a session is deleting the row. */ export function makeOpaqueSession(args: { - /** Stores a session row and returns its id */ - store: (row: Session) => Promise<{ id: string }>; - /** Reads a session row by id, null when there is none */ - get: (id: string) => Promise; + /** Stores a session row under the token */ + store: (token: string, row: Session) => Promise; + /** Reads the session row for a token, null when there is none */ + get: (token: string) => Promise; }) { return { make: async ( proof: Proof, resolve: (proven: T) => Promise, ) => { - const { id } = await args.store(await resolve(Proof.spend(proof))); + const session = await resolve(Proof.spend(proof)); + const token = crypto.randomUUID(); - return id; + await args.store(token, session); + + return token; }, get: (token: string) => args.get(token), diff --git a/packages/spike/src/strategies/otp.ts b/packages/spike/src/strategies/otp.ts index 456fd33..40c9419 100644 --- a/packages/spike/src/strategies/otp.ts +++ b/packages/spike/src/strategies/otp.ts @@ -1,25 +1,30 @@ import { Proof, fail } from "../proof"; export function makeOTP(args: { - /** Stores an otp row and returns its id */ - store: (row: { identifier: string; otp: string }) => Promise<{ id: string }>; - /** Removes an otp row by id and returns it, atomically. Null when there is none. */ - take: (id: string) => Promise<{ identifier: string; otp: string } | null>; + /** Stores an otp row under the ticket */ + store: ( + ticket: string, + row: { identifier: string; otp: string }, + ) => Promise; + /** Removes the otp row for a ticket and returns it, atomically. Null when there is none. */ + take: (ticket: string) => Promise<{ identifier: string; otp: string } | null>; /** Delivers the otp to the identifier, an email address or a phone number */ send: (identifier: string, otp: string) => Promise; }) { return { + /** Returns the ticket the requesting client holds until it verifies */ send: async (identifier: string) => { + const ticket = crypto.randomUUID(); const otp = crypto.randomUUID(); - const { id } = await args.store({ identifier, otp }); + await args.store(ticket, { identifier, otp }); await args.send(identifier, otp); - return id; + return ticket; }, - verify: async ({ id, otp }: { id: string; otp: string }) => { - const row = await args.take(id); + verify: async ({ ticket, otp }: { ticket: string; otp: string }) => { + const row = await args.take(ticket); if (row === null) return fail("unknown"); if (row.otp !== otp) return fail("mismatch"); diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts index 48e9ee9..5ffdb7d 100644 --- a/packages/spike/src/strategies/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -16,19 +16,18 @@ export function makePasskey(args: { rpId: string; /** The relying party name, shown by the authenticator */ rpName: string; - /** Stores a challenge row and returns its id, which is the challenge */ - storeChallenge: (row: Challenge) => Promise<{ id: string }>; - /** Removes a challenge row by id and returns it, atomically. Null when there is none. */ - takeChallenge: (id: string) => Promise; + /** Stores a challenge row under the challenge */ + storeChallenge: (challenge: string, row: Challenge) => Promise; + /** Removes the row for a challenge and returns it, atomically. Null when there is none. */ + takeChallenge: (challenge: string) => Promise; /** Stores a credential row under the id the authenticator chose */ - storeCredential: (row: { - id: string; - publicKey: string; - handle: string; - }) => Promise; - /** Reads a credential row by id, null when there is none */ + storeCredential: ( + credentialId: string, + row: { publicKey: string; handle: string }, + ) => Promise; + /** Reads the credential row for an id, null when there is none */ getCredential: ( - id: string, + credentialId: string, ) => Promise<{ publicKey: string; handle: string } | null>; }) { return { @@ -44,10 +43,12 @@ export function makePasskey(args: { handle: string; name: string; }) => { - const { id } = await args.storeChallenge({ purpose: "register", handle }); + const challenge = crypto.randomUUID(); + + await args.storeChallenge(challenge, { purpose: "register", handle }); return { - challenge: id, + challenge, rp: { id: args.rpId, name: args.rpName }, user: { id: handle, name }, }; @@ -67,8 +68,7 @@ export function makePasskey(args: { if (row === null) return fail("challenge"); if (row.purpose !== "register") return fail("challenge"); - await args.storeCredential({ - id: credentialId, + await args.storeCredential(credentialId, { publicKey, handle: row.handle, }); @@ -77,9 +77,11 @@ export function makePasskey(args: { }, beginAuthentication: async () => { - const { id } = await args.storeChallenge({ purpose: "authenticate" }); + const challenge = crypto.randomUUID(); + + await args.storeChallenge(challenge, { purpose: "authenticate" }); - return { challenge: id, rpId: args.rpId }; + return { challenge, rpId: args.rpId }; }, finishAuthentication: async ({ From d3d4686b5e227bcac543c1e72e3e7fb91e5ecc0d Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 10:05:22 -0700 Subject: [PATCH 25/43] Close the library to extension --- packages/spike/README.md | 5 ++ packages/spike/package.json | 3 ++ packages/spike/spike-no-kernel.ts | 8 +--- packages/spike/src/failure.ts | 6 +++ packages/spike/src/index.ts | 5 +- packages/spike/src/index.typecheck.ts | 21 ++++++++ packages/spike/src/proof.ts | 61 +++++++++++++----------- packages/spike/src/proof.typecheck.ts | 22 +++++++++ packages/spike/src/sessions/opaque.ts | 4 +- packages/spike/src/sessions/signed.ts | 4 +- packages/spike/src/strategies/otp.ts | 5 +- packages/spike/src/strategies/passkey.ts | 7 +-- 12 files changed, 102 insertions(+), 49 deletions(-) create mode 100644 packages/spike/src/failure.ts create mode 100644 packages/spike/src/index.typecheck.ts create mode 100644 packages/spike/src/proof.typecheck.ts diff --git a/packages/spike/README.md b/packages/spike/README.md index 21de026..eb79f23 100644 --- a/packages/spike/README.md +++ b/packages/spike/README.md @@ -5,6 +5,7 @@ - An OTP is verified against the request that asked for it, not the identifier. The requesting client holds a random ticket from `send`, and the OTP alone is useless without it. Signing in on another device is a magic link's job. - The library generates every string it hands a client. OTP tickets, passkey challenges, and session tokens are random columns the library fills, never the app's ids. The app keys its tables however it likes. - A different trade-off is a different factory. An OTP verified against the identifier, with nothing for the client to hold, would sit beside the ticket one rather than change it. +- The library is not extensible. It ships the strategies and sessions, and an app wires them and writes none of its own. `issueProof` and `consumeProof` are not exported, so an agent cannot shortcut sign-in through the library and have it look like proper use. Auth is what everyone gets wrong, agents most of all, so the scrutiny belongs in one place. A new strategy or session is a pull request. ## OTP lookup options @@ -22,3 +23,7 @@ The identifier is the email address or phone number the OTP is sent to. | A stranger requests an OTP for your identifier | yours is untouched | yours stops working | one more OTP that would work | Ticket is the only option where an OTP is bound to the client that asked and where a stranger's request changes nothing for you. Identifier with one pending is the simplest for the app and the weakest against interference. Identifier with many pending has the costs of both and should not be built. + +## Revisit + +- Opening the library to bespoke strategies and sessions, by exporting `issueProof` and `consumeProof` or a `makeStrategy` factory around them. Either hands the shortcut back, so only on real demand. diff --git a/packages/spike/package.json b/packages/spike/package.json index a531974..a0d32e6 100644 --- a/packages/spike/package.json +++ b/packages/spike/package.json @@ -2,6 +2,9 @@ "name": "@repo/spike", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "typecheck": "tsc" } diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 59d96ae..b4ae1b7 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -5,7 +5,6 @@ import { makePasskey, makeSignedSession, type Challenge, - type Proof, } from "./src/index"; import { createTable } from "./spike-helpers"; @@ -214,13 +213,8 @@ try { console.log("REUSE", error instanceof Error ? error.message : error); } -// The guards. Never called, they exist to show what does not compile. +// The guard. Never called, it exists to show what does not compile. export function withoutProof(userId: string) { // @ts-expect-error a session cannot be made without a proof return opaque.make({ userId }, async () => ({ userId })); } - -export function mintProof() { - // @ts-expect-error the app cannot mint a proof, only the type is exported - return Proof.prove({ userId: "anyone" }); -} diff --git a/packages/spike/src/failure.ts b/packages/spike/src/failure.ts new file mode 100644 index 0000000..ccab797 --- /dev/null +++ b/packages/spike/src/failure.ts @@ -0,0 +1,6 @@ +/** An expected failure the app branches on. The reason names what did not hold. */ +export type Failure = { reason: Reason }; + +export function fail(reason: Reason): Failure { + return { reason }; +} diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts index ec373c7..df2b7f8 100644 --- a/packages/spike/src/index.ts +++ b/packages/spike/src/index.ts @@ -1,8 +1,5 @@ -export type { Proof, Failure } from "./proof"; export { isProof } from "./proof"; -export type { SessionContract } from "./sessions/contract"; export { makeOpaqueSession } from "./sessions/opaque"; export { makeSignedSession } from "./sessions/signed"; export { makeOTP } from "./strategies/otp"; -export { makePasskey } from "./strategies/passkey"; -export type { Challenge } from "./strategies/passkey"; +export { makePasskey, type Challenge } from "./strategies/passkey"; diff --git a/packages/spike/src/index.typecheck.ts b/packages/spike/src/index.typecheck.ts new file mode 100644 index 0000000..aa814a3 --- /dev/null +++ b/packages/spike/src/index.typecheck.ts @@ -0,0 +1,21 @@ +/** + * The export guards written during the spike, kept so they are not lost. + * Typechecked, never run. Each expected error proves a name is absent from + * the public entry. + */ + +// @ts-expect-error only the library issues proofs +import { issueProof } from "./index"; +// @ts-expect-error only the library consumes proofs +import { consumeProof } from "./index"; +// @ts-expect-error failures are made by strategies, the app only reads them +import { fail } from "./index"; +// @ts-expect-error the session contract is for the library's own sessions +import type { SessionContract } from "./index"; +// @ts-expect-error an app never has to name a proof +import type { Proof } from "./index"; +// @ts-expect-error an app never has to name a failure +import type { Failure } from "./index"; + +export const absent = [issueProof, consumeProof, fail]; +export type Absent = [SessionContract, Proof, Failure]; diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts index fbc6ef4..829a681 100644 --- a/packages/spike/src/proof.ts +++ b/packages/spike/src/proof.ts @@ -1,44 +1,47 @@ +import type { Failure } from "./failure"; + +/** Marks the type so a proof cannot be written by hand. Never exported. */ +const brand: unique symbol = Symbol("proof"); + /** - * What a strategy proved. Only a strategy can construct one, and a session - * cannot be made without one. The public entry exports the type only, so - * the app can name a proof but never make one. + * A receipt for what a strategy proved. A strategy issues one and a session + * consumes it. The library remembers every proof it issues and consumes only + * those, so an object that only looks like a proof is refused. A proof is an + * object in the server's memory and never leaves the process that issued it, + * so it is issued and consumed in the same call. */ -export class Proof { - readonly proven: T; - private used = false; +export type Proof = { readonly proven: T; readonly [brand]: true }; - private constructor(proven: T) { - this.proven = proven; - } +/** Every proof issued and not yet consumed */ +const issued = new WeakSet(); - static prove(proven: T) { - return new Proof(proven); - } +/** A strategy ends by calling this. Nothing else makes a proof. */ +export function issueProof(proven: T): Proof { + const proof = Object.freeze({ + proven: Object.freeze({ ...proven }), + [brand]: true as const, + }); - /** - * Spends a proof and returns what it proved. Every session implementation - * calls this first, so the rule lives here and not in each of them. A - * second call with the same proof throws. - */ - static spend(proof: Proof): T { - if (!(proof instanceof Proof)) throw new Error("not a proof"); - if (proof.used) throw new Error("proof already used"); - proof.used = true; - - return proof.proven; - } + issued.add(proof); + + return proof; } -/** An expected failure the app branches on. The reason names what did not hold. */ -export type Failure = { reason: Reason }; +/** + * A session starts by calling this. Returns what was proven. Throws for a + * proof that was never issued or was already consumed. + */ +export function consumeProof(proof: Proof): T { + if (!issued.delete(proof)) { + throw new Error("not a proof, or already consumed"); + } -export function fail(reason: Reason): Failure { - return { reason }; + return proof.proven; } /** Narrows a strategy's result to the proof. The app's way to tell them apart. */ export function isProof( value: Proof | Failure, ): value is Proof { - return value instanceof Proof; + return issued.has(value); } diff --git a/packages/spike/src/proof.typecheck.ts b/packages/spike/src/proof.typecheck.ts new file mode 100644 index 0000000..209f413 --- /dev/null +++ b/packages/spike/src/proof.typecheck.ts @@ -0,0 +1,22 @@ +/** + * The compile guards written during the spike, kept so they are not lost. + * Typechecked, never run. + */ +import type { Proof } from "./proof"; +import type { makeOpaqueSession } from "./sessions/opaque"; + +declare const sessions: ReturnType< + typeof makeOpaqueSession<{ userId: string }> +>; + +// @ts-expect-error a proof cannot be written by hand +const written: Proof<{ identifier: string }> = { + proven: { identifier: "victim@example.com" }, +}; +void written; + +void sessions.make( + // @ts-expect-error a session cannot be made without a proof + { proven: { identifier: "victim@example.com" } }, + async () => ({ userId: "victim" }), +); diff --git a/packages/spike/src/sessions/opaque.ts b/packages/spike/src/sessions/opaque.ts index 772a813..9f85f0e 100644 --- a/packages/spike/src/sessions/opaque.ts +++ b/packages/spike/src/sessions/opaque.ts @@ -1,4 +1,4 @@ -import { Proof } from "../proof"; +import { consumeProof, type Proof } from "../proof"; import type { SessionContract } from "./contract"; /** The token is a random string the row is stored under. Ending a session is deleting the row. */ @@ -13,7 +13,7 @@ export function makeOpaqueSession(args: { proof: Proof, resolve: (proven: T) => Promise, ) => { - const session = await resolve(Proof.spend(proof)); + const session = await resolve(consumeProof(proof)); const token = crypto.randomUUID(); await args.store(token, session); diff --git a/packages/spike/src/sessions/signed.ts b/packages/spike/src/sessions/signed.ts index 0eebf05..2109169 100644 --- a/packages/spike/src/sessions/signed.ts +++ b/packages/spike/src/sessions/signed.ts @@ -1,4 +1,4 @@ -import { Proof } from "../proof"; +import { consumeProof, type Proof } from "../proof"; import type { SessionContract } from "./contract"; /** @@ -25,7 +25,7 @@ export function makeSignedSession(args: { proof: Proof, resolve: (proven: T) => Promise, ) => { - const session = await resolve(Proof.spend(proof)); + const session = await resolve(consumeProof(proof)); const payload = encode( JSON.stringify({ session, exp: Date.now() + args.ttl }), ); diff --git a/packages/spike/src/strategies/otp.ts b/packages/spike/src/strategies/otp.ts index 40c9419..3dac045 100644 --- a/packages/spike/src/strategies/otp.ts +++ b/packages/spike/src/strategies/otp.ts @@ -1,4 +1,5 @@ -import { Proof, fail } from "../proof"; +import { fail } from "../failure"; +import { issueProof } from "../proof"; export function makeOTP(args: { /** Stores an otp row under the ticket */ @@ -29,7 +30,7 @@ export function makeOTP(args: { if (row === null) return fail("unknown"); if (row.otp !== otp) return fail("mismatch"); - return Proof.prove({ identifier: row.identifier }); + return issueProof({ identifier: row.identifier }); }, }; } diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts index 5ffdb7d..37627b2 100644 --- a/packages/spike/src/strategies/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -1,4 +1,5 @@ -import { Proof, fail } from "../proof"; +import { fail } from "../failure"; +import { issueProof } from "../proof"; /** * Fake. No WebAuthn, the "signature" is the public key sent back as is. @@ -73,7 +74,7 @@ export function makePasskey(args: { handle: row.handle, }); - return Proof.prove({ credentialId, userHandle: row.handle }); + return issueProof({ credentialId, userHandle: row.handle }); }, beginAuthentication: async () => { @@ -106,7 +107,7 @@ export function makePasskey(args: { if (credential.publicKey !== signature) return fail("signature"); if (credential.handle !== userHandle) return fail("handle"); - return Proof.prove({ credentialId, userHandle }); + return issueProof({ credentialId, userHandle }); }, }; } From 6c6c1bd618fd0bc5cbea1f8c557de57d2de31023 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 10:36:43 -0700 Subject: [PATCH 26/43] Return results like safeParse --- packages/spike/spike-no-kernel.ts | 34 +++++++++++---------- packages/spike/src/failure.ts | 29 +++++++++++++++--- packages/spike/src/index.ts | 1 - packages/spike/src/proof.ts | 9 ------ packages/spike/src/strategies/otp.ts | 20 ++++++++---- packages/spike/src/strategies/passkey.ts | 39 ++++++++++++++++-------- 6 files changed, 83 insertions(+), 49 deletions(-) diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index b4ae1b7..b9a59c9 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -1,5 +1,4 @@ import { - isProof, makeOpaqueSession, makeOTP, makePasskey, @@ -78,10 +77,10 @@ const emailProof = await emailOtp.verify({ ticket: emailTicket, otp: delivered.get("ripley@example.com") ?? "", }); -if (!isProof(emailProof)) throw new Error(emailProof.reason); +if (!emailProof.success) throw new Error(emailProof.error); const emailSessionId = await opaque.make( - emailProof, + emailProof.data, async ({ identifier }) => ({ userId: `user-for-${identifier}`, }), @@ -95,11 +94,14 @@ const smsProof = await smsOtp.verify({ ticket: smsTicket, otp: delivered.get("+15555550100") ?? "", }); -if (!isProof(smsProof)) throw new Error(smsProof.reason); +if (!smsProof.success) throw new Error(smsProof.error); -const smsSessionId = await opaque.make(smsProof, async ({ identifier }) => ({ - userId: `user-for-${identifier}`, -})); +const smsSessionId = await opaque.make( + smsProof.data, + async ({ identifier }) => ({ + userId: `user-for-${identifier}`, + }), +); console.log("SESSION", await opaque.get(smsSessionId)); // Passkey sign-up. 1. app creates the user, 2. begin with its id as the @@ -117,11 +119,11 @@ const registered = await passkey.finishRegistration({ credentialId: newCredentialId, publicKey: newKey, }); -if (!isProof(registered)) throw new Error(registered.reason); +if (!registered.success) throw new Error(registered.error); authenticator.set(newCredentialId, { key: newKey, handle: signUp.user.id }); const signUpSessionId = await opaque.make( - registered, + registered.data, async ({ userHandle }) => ({ userId: userHandle }), ); console.log("SESSION", await opaque.get(signUpSessionId)); @@ -138,10 +140,10 @@ const authenticated = await passkey.finishAuthentication({ signature: stored.key, userHandle: stored.handle, }); -if (!isProof(authenticated)) throw new Error(authenticated.reason); +if (!authenticated.success) throw new Error(authenticated.error); const signInSessionId = await opaque.make( - authenticated, + authenticated.data, async ({ userHandle }) => ({ userId: userHandle }), ); console.log("SESSION", await opaque.get(signInSessionId)); @@ -162,9 +164,9 @@ const added = await passkey.finishRegistration({ credentialId: secondCredentialId, publicKey: secondKey, }); -if (!isProof(added)) throw new Error(added.reason); +if (!added.success) throw new Error(added.error); authenticator.set(secondCredentialId, { key: secondKey, handle: add.user.id }); -console.log("ADDED", added.proven); +console.log("ADDED", added.data.proven); // Signed session. Same three steps, no table. The token carries the session const signed = makeSignedSession<{ userId: string }>({ @@ -177,9 +179,9 @@ const signedProof = await emailOtp.verify({ ticket: signedTicket, otp: delivered.get("ripley@example.com") ?? "", }); -if (!isProof(signedProof)) throw new Error(signedProof.reason); +if (!signedProof.success) throw new Error(signedProof.error); -const token = await signed.make(signedProof, async ({ identifier }) => ({ +const token = await signed.make(signedProof.data, async ({ identifier }) => ({ userId: `user-for-${identifier}`, })); console.log("TOKEN", token); @@ -208,7 +210,7 @@ console.log( // Reuse the proof. Rejected at runtime try { - await opaque.make(emailProof, async () => ({ userId: "someone-else" })); + await opaque.make(emailProof.data, async () => ({ userId: "someone-else" })); } catch (error) { console.log("REUSE", error instanceof Error ? error.message : error); } diff --git a/packages/spike/src/failure.ts b/packages/spike/src/failure.ts index ccab797..4081ee5 100644 --- a/packages/spike/src/failure.ts +++ b/packages/spike/src/failure.ts @@ -1,6 +1,27 @@ -/** An expected failure the app branches on. The reason names what did not hold. */ -export type Failure = { reason: Reason }; +/** + * Every reason a strategy can fail with. A reason is added here before + * anything can fail with it. + */ +type Reason = + | "unknown_ticket" + | "wrong_otp" + | "unknown_challenge" + | "unknown_credential" + | "wrong_signature" + | "wrong_handle"; -export function fail(reason: Reason): Failure { - return { reason }; +/** What a strategy returns. Narrow on success, then read data or error. */ +export type Result = Success | Failure; + +type Success = { success: true; data: T }; + +/** An expected failure the app branches on. The error names what did not hold. */ +type Failure = { success: false; error: E }; + +export function succeed(data: T): Success { + return { success: true, data }; +} + +export function fail(reason: E): Failure { + return { success: false, error: reason }; } diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts index df2b7f8..c3f35a2 100644 --- a/packages/spike/src/index.ts +++ b/packages/spike/src/index.ts @@ -1,4 +1,3 @@ -export { isProof } from "./proof"; export { makeOpaqueSession } from "./sessions/opaque"; export { makeSignedSession } from "./sessions/signed"; export { makeOTP } from "./strategies/otp"; diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts index 829a681..84e8929 100644 --- a/packages/spike/src/proof.ts +++ b/packages/spike/src/proof.ts @@ -1,5 +1,3 @@ -import type { Failure } from "./failure"; - /** Marks the type so a proof cannot be written by hand. Never exported. */ const brand: unique symbol = Symbol("proof"); @@ -38,10 +36,3 @@ export function consumeProof(proof: Proof): T { return proof.proven; } - -/** Narrows a strategy's result to the proof. The app's way to tell them apart. */ -export function isProof( - value: Proof | Failure, -): value is Proof { - return issued.has(value); -} diff --git a/packages/spike/src/strategies/otp.ts b/packages/spike/src/strategies/otp.ts index 3dac045..33c333b 100644 --- a/packages/spike/src/strategies/otp.ts +++ b/packages/spike/src/strategies/otp.ts @@ -1,5 +1,5 @@ -import { fail } from "../failure"; -import { issueProof } from "../proof"; +import { fail, succeed, type Result } from "../failure"; +import { issueProof, type Proof } from "../proof"; export function makeOTP(args: { /** Stores an otp row under the ticket */ @@ -24,13 +24,21 @@ export function makeOTP(args: { return ticket; }, - verify: async ({ ticket, otp }: { ticket: string; otp: string }) => { + verify: async ({ + ticket, + otp, + }: { + ticket: string; + otp: string; + }): Promise< + Result, "unknown_ticket" | "wrong_otp"> + > => { const row = await args.take(ticket); - if (row === null) return fail("unknown"); - if (row.otp !== otp) return fail("mismatch"); + if (row === null) return fail("unknown_ticket"); + if (row.otp !== otp) return fail("wrong_otp"); - return issueProof({ identifier: row.identifier }); + return succeed(issueProof({ identifier: row.identifier })); }, }; } diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts index 37627b2..72cfc63 100644 --- a/packages/spike/src/strategies/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -1,5 +1,5 @@ -import { fail } from "../failure"; -import { issueProof } from "../proof"; +import { fail, succeed, type Result } from "../failure"; +import { issueProof, type Proof } from "../proof"; /** * Fake. No WebAuthn, the "signature" is the public key sent back as is. @@ -63,18 +63,23 @@ export function makePasskey(args: { challenge: string; credentialId: string; publicKey: string; - }) => { + }): Promise< + Result< + Proof<{ credentialId: string; userHandle: string }>, + "unknown_challenge" + > + > => { const row = await args.takeChallenge(challenge); - if (row === null) return fail("challenge"); - if (row.purpose !== "register") return fail("challenge"); + if (row === null) return fail("unknown_challenge"); + if (row.purpose !== "register") return fail("unknown_challenge"); await args.storeCredential(credentialId, { publicKey, handle: row.handle, }); - return issueProof({ credentialId, userHandle: row.handle }); + return succeed(issueProof({ credentialId, userHandle: row.handle })); }, beginAuthentication: async () => { @@ -95,19 +100,27 @@ export function makePasskey(args: { credentialId: string; signature: string; userHandle: string; - }) => { + }): Promise< + Result< + Proof<{ credentialId: string; userHandle: string }>, + | "unknown_challenge" + | "unknown_credential" + | "wrong_signature" + | "wrong_handle" + > + > => { const row = await args.takeChallenge(challenge); - if (row === null) return fail("challenge"); - if (row.purpose !== "authenticate") return fail("challenge"); + if (row === null) return fail("unknown_challenge"); + if (row.purpose !== "authenticate") return fail("unknown_challenge"); const credential = await args.getCredential(credentialId); - if (credential === null) return fail("credential"); - if (credential.publicKey !== signature) return fail("signature"); - if (credential.handle !== userHandle) return fail("handle"); + if (credential === null) return fail("unknown_credential"); + if (credential.publicKey !== signature) return fail("wrong_signature"); + if (credential.handle !== userHandle) return fail("wrong_handle"); - return issueProof({ credentialId, userHandle }); + return succeed(issueProof({ credentialId, userHandle })); }, }; } From 841920347c694baabce00e9216d53e581243d156 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 10:39:01 -0700 Subject: [PATCH 27/43] Rename --- packages/spike/spike-helpers.ts | 2 +- packages/spike/spike-no-kernel.ts | 10 +++++----- packages/spike/spike.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/spike/spike-helpers.ts b/packages/spike/spike-helpers.ts index 84c2280..58143af 100644 --- a/packages/spike/spike-helpers.ts +++ b/packages/spike/spike-helpers.ts @@ -1,4 +1,4 @@ -export function createTable() { +export function makeTable() { const data = new Map(); const insert = (row: V) => { diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index b9a59c9..52fa332 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -5,16 +5,16 @@ import { makeSignedSession, type Challenge, } from "./src/index"; -import { createTable } from "./spike-helpers"; +import { makeTable } from "./spike-helpers"; /** * App */ -const sessionsTable = createTable<{ userId: string }>(); -const otpsTable = createTable<{ identifier: string; otp: string }>(); -const challengesTable = createTable(); -const credentialsTable = createTable<{ publicKey: string; handle: string }>(); +const sessionsTable = makeTable<{ userId: string }>(); +const otpsTable = makeTable<{ identifier: string; otp: string }>(); +const challengesTable = makeTable(); +const credentialsTable = makeTable<{ publicKey: string; handle: string }>(); // The fake authenticator in the browser. Credential id to its key and handle. const authenticator = new Map(); diff --git a/packages/spike/spike.ts b/packages/spike/spike.ts index 56c7d4f..bc7e2ab 100644 --- a/packages/spike/spike.ts +++ b/packages/spike/spike.ts @@ -1,16 +1,16 @@ -import { createTable } from "./spike-helpers"; +import { makeTable } from "./spike-helpers"; const _email = "ripley@example.com"; let _interceptedOneTimePasscode = ""; // The app's own table. Keyed by a random id, email is a column. -const usersTable = createTable<{ email: string }>(); +const usersTable = makeTable<{ email: string }>(); // Opaque session. Remembers the user id under a random id. -const sessionsTable = createTable<{ userId: string }>(); +const sessionsTable = makeTable<{ userId: string }>(); // OTP. One row per sent otp, keyed by a random id. Checked once. -const otpsTable = createTable<{ email: string; otp: string }>(); +const otpsTable = makeTable<{ email: string; otp: string }>(); /** * Session From 4f30c00f219a237cb92c3005ff0c17271c6ec4c8 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 11:54:18 -0700 Subject: [PATCH 28/43] Rename sessions to session managers --- packages/spike/README.md | 18 ++++++++++++++++-- packages/spike/spike-no-kernel.ts | 11 ++++++----- packages/spike/src/index.ts | 4 ++-- packages/spike/src/index.typecheck.ts | 6 +++--- packages/spike/src/proof.ts | 12 ++++++------ packages/spike/src/proof.typecheck.ts | 8 ++++---- packages/spike/src/sessions/contract.ts | 8 ++++---- packages/spike/src/sessions/opaque.ts | 6 +++--- packages/spike/src/sessions/signed.ts | 6 +++--- 9 files changed, 47 insertions(+), 32 deletions(-) diff --git a/packages/spike/README.md b/packages/spike/README.md index eb79f23..eff0778 100644 --- a/packages/spike/README.md +++ b/packages/spike/README.md @@ -1,11 +1,25 @@ # Spike +This library consists of three main entities. + +- Session managers +- Authentication strategies +- Proofs + +This is how they relate. + +- A strategy authenticates and issues a proof of what was proven. +- A session manager consumes the proof and issues a session. +- The app hands the proof from one to the other and decides what the session holds. + +Strategies and session managers are completely independent. They communicate using proofs. + ## Decisions - An OTP is verified against the request that asked for it, not the identifier. The requesting client holds a random ticket from `send`, and the OTP alone is useless without it. Signing in on another device is a magic link's job. - The library generates every string it hands a client. OTP tickets, passkey challenges, and session tokens are random columns the library fills, never the app's ids. The app keys its tables however it likes. - A different trade-off is a different factory. An OTP verified against the identifier, with nothing for the client to hold, would sit beside the ticket one rather than change it. -- The library is not extensible. It ships the strategies and sessions, and an app wires them and writes none of its own. `issueProof` and `consumeProof` are not exported, so an agent cannot shortcut sign-in through the library and have it look like proper use. Auth is what everyone gets wrong, agents most of all, so the scrutiny belongs in one place. A new strategy or session is a pull request. +- The library is not extensible. It ships the strategies and session managers, and an app wires them and writes none of its own. `issueProof` and `consumeProof` are not exported, so an agent cannot shortcut sign-in through the library and have it look like proper use. Auth is what everyone gets wrong, agents most of all, so the scrutiny belongs in one place. A new strategy or session manager is a pull request. ## OTP lookup options @@ -26,4 +40,4 @@ Ticket is the only option where an OTP is bound to the client that asked and whe ## Revisit -- Opening the library to bespoke strategies and sessions, by exporting `issueProof` and `consumeProof` or a `makeStrategy` factory around them. Either hands the shortcut back, so only on real demand. +- Opening the library to bespoke strategies and session managers, by exporting `issueProof` and `consumeProof` or a `makeStrategy` factory around them. Either hands the shortcut back, so only on real demand. diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 52fa332..61a5265 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -1,8 +1,8 @@ import { - makeOpaqueSession, + makeOpaqueSessionManager, makeOTP, makePasskey, - makeSignedSession, + makeSignedSessionManager, type Challenge, } from "./src/index"; import { makeTable } from "./spike-helpers"; @@ -22,7 +22,7 @@ const authenticator = new Map(); // Captures what would have been delivered, for the demo const delivered = new Map(); -const opaque = makeOpaqueSession<{ userId: string }>({ +const opaque = makeOpaqueSessionManager<{ userId: string }>({ store: async (token, row) => { await sessionsTable.put(token, row); }, @@ -168,8 +168,9 @@ if (!added.success) throw new Error(added.error); authenticator.set(secondCredentialId, { key: secondKey, handle: add.user.id }); console.log("ADDED", added.data.proven); -// Signed session. Same three steps, no table. The token carries the session -const signed = makeSignedSession<{ userId: string }>({ +// Signed session manager. Same three steps, no table. The token carries the +// session +const signed = makeSignedSessionManager<{ userId: string }>({ secret: "spike-secret", ttl: 60 * 60 * 1000, }); diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts index c3f35a2..19a1308 100644 --- a/packages/spike/src/index.ts +++ b/packages/spike/src/index.ts @@ -1,4 +1,4 @@ -export { makeOpaqueSession } from "./sessions/opaque"; -export { makeSignedSession } from "./sessions/signed"; +export { makeOpaqueSessionManager } from "./sessions/opaque"; +export { makeSignedSessionManager } from "./sessions/signed"; export { makeOTP } from "./strategies/otp"; export { makePasskey, type Challenge } from "./strategies/passkey"; diff --git a/packages/spike/src/index.typecheck.ts b/packages/spike/src/index.typecheck.ts index aa814a3..ed42ba6 100644 --- a/packages/spike/src/index.typecheck.ts +++ b/packages/spike/src/index.typecheck.ts @@ -10,12 +10,12 @@ import { issueProof } from "./index"; import { consumeProof } from "./index"; // @ts-expect-error failures are made by strategies, the app only reads them import { fail } from "./index"; -// @ts-expect-error the session contract is for the library's own sessions -import type { SessionContract } from "./index"; +// @ts-expect-error the contract is for the library's own session managers +import type { SessionManager } from "./index"; // @ts-expect-error an app never has to name a proof import type { Proof } from "./index"; // @ts-expect-error an app never has to name a failure import type { Failure } from "./index"; export const absent = [issueProof, consumeProof, fail]; -export type Absent = [SessionContract, Proof, Failure]; +export type Absent = [SessionManager, Proof, Failure]; diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts index 84e8929..3f7d247 100644 --- a/packages/spike/src/proof.ts +++ b/packages/spike/src/proof.ts @@ -3,10 +3,10 @@ const brand: unique symbol = Symbol("proof"); /** * A receipt for what a strategy proved. A strategy issues one and a session - * consumes it. The library remembers every proof it issues and consumes only - * those, so an object that only looks like a proof is refused. A proof is an - * object in the server's memory and never leaves the process that issued it, - * so it is issued and consumed in the same call. + * manager consumes it. The library remembers every proof it issues and + * consumes only those, so an object that only looks like a proof is refused. + * A proof is an object in the server's memory and never leaves the process + * that issued it, so it is issued and consumed in the same call. */ export type Proof = { readonly proven: T; readonly [brand]: true }; @@ -26,8 +26,8 @@ export function issueProof(proven: T): Proof { } /** - * A session starts by calling this. Returns what was proven. Throws for a - * proof that was never issued or was already consumed. + * A session manager starts by calling this. Returns what was proven. Throws + * for a proof that was never issued or was already consumed. */ export function consumeProof(proof: Proof): T { if (!issued.delete(proof)) { diff --git a/packages/spike/src/proof.typecheck.ts b/packages/spike/src/proof.typecheck.ts index 209f413..c4d4cab 100644 --- a/packages/spike/src/proof.typecheck.ts +++ b/packages/spike/src/proof.typecheck.ts @@ -3,10 +3,10 @@ * Typechecked, never run. */ import type { Proof } from "./proof"; -import type { makeOpaqueSession } from "./sessions/opaque"; +import type { makeOpaqueSessionManager } from "./sessions/opaque"; -declare const sessions: ReturnType< - typeof makeOpaqueSession<{ userId: string }> +declare const sessionManager: ReturnType< + typeof makeOpaqueSessionManager<{ userId: string }> >; // @ts-expect-error a proof cannot be written by hand @@ -15,7 +15,7 @@ const written: Proof<{ identifier: string }> = { }; void written; -void sessions.make( +void sessionManager.make( // @ts-expect-error a session cannot be made without a proof { proven: { identifier: "victim@example.com" } }, async () => ({ userId: "victim" }), diff --git a/packages/spike/src/sessions/contract.ts b/packages/spike/src/sessions/contract.ts index 53b6e0a..ebfbd81 100644 --- a/packages/spike/src/sessions/contract.ts +++ b/packages/spike/src/sessions/contract.ts @@ -1,12 +1,12 @@ import type { Proof } from "../proof"; /** - * What every session implementation provides. make spends a proof and + * What every session manager provides. make spends a proof and * returns a token, get turns a token back into the session or null. Anything - * else a mechanism can do, such as ending a session, is its own method beside - * these two. Session is what the app decides a session is. + * else a session manager can do, such as ending a session, is its own method + * beside these two. Session is what the app decides a session is. */ -export type SessionContract = { +export type SessionManager = { make: ( proof: Proof, resolve: (proven: T) => Promise, diff --git a/packages/spike/src/sessions/opaque.ts b/packages/spike/src/sessions/opaque.ts index 9f85f0e..bbc498e 100644 --- a/packages/spike/src/sessions/opaque.ts +++ b/packages/spike/src/sessions/opaque.ts @@ -1,8 +1,8 @@ import { consumeProof, type Proof } from "../proof"; -import type { SessionContract } from "./contract"; +import type { SessionManager } from "./contract"; /** The token is a random string the row is stored under. Ending a session is deleting the row. */ -export function makeOpaqueSession(args: { +export function makeOpaqueSessionManager(args: { /** Stores a session row under the token */ store: (token: string, row: Session) => Promise; /** Reads the session row for a token, null when there is none */ @@ -22,5 +22,5 @@ export function makeOpaqueSession(args: { }, get: (token: string) => args.get(token), - } satisfies SessionContract; + } satisfies SessionManager; } diff --git a/packages/spike/src/sessions/signed.ts b/packages/spike/src/sessions/signed.ts index 2109169..3e7f8f9 100644 --- a/packages/spike/src/sessions/signed.ts +++ b/packages/spike/src/sessions/signed.ts @@ -1,12 +1,12 @@ import { consumeProof, type Proof } from "../proof"; -import type { SessionContract } from "./contract"; +import type { SessionManager } from "./contract"; /** * Stateless. The session travels inside the token, signed so it cannot be * altered. Nothing is stored, so there is nothing to end. A token is valid * until it expires, which is why ttl is not optional here. */ -export function makeSignedSession(args: { +export function makeSignedSessionManager(args: { /** HMAC secret. Anyone holding it can mint a session. */ secret: string; /** Lifetime of a token in ms */ @@ -61,7 +61,7 @@ export function makeSignedSession(args: { return session; }, - } satisfies SessionContract; + } satisfies SessionManager; } function bytes(value: string) { From e8b47ab17d4a19a22bb33368320652737469617b Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 12:11:54 -0700 Subject: [PATCH 29/43] Rename --- packages/spike/src/{failure.ts => result.ts} | 0 packages/spike/src/strategies/otp.ts | 2 +- packages/spike/src/strategies/passkey.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/spike/src/{failure.ts => result.ts} (100%) diff --git a/packages/spike/src/failure.ts b/packages/spike/src/result.ts similarity index 100% rename from packages/spike/src/failure.ts rename to packages/spike/src/result.ts diff --git a/packages/spike/src/strategies/otp.ts b/packages/spike/src/strategies/otp.ts index 33c333b..546c5bc 100644 --- a/packages/spike/src/strategies/otp.ts +++ b/packages/spike/src/strategies/otp.ts @@ -1,4 +1,4 @@ -import { fail, succeed, type Result } from "../failure"; +import { fail, succeed, type Result } from "../result"; import { issueProof, type Proof } from "../proof"; export function makeOTP(args: { diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts index 72cfc63..241252b 100644 --- a/packages/spike/src/strategies/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -1,4 +1,4 @@ -import { fail, succeed, type Result } from "../failure"; +import { fail, succeed, type Result } from "../result"; import { issueProof, type Proof } from "../proof"; /** From fcc5f93b359c832622fae31147f5a050a14a4fc2 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 14:40:32 -0700 Subject: [PATCH 30/43] Make the spike OTP real --- packages/spike/spike-no-kernel.ts | 18 ++++-- packages/spike/src/index.ts | 2 +- packages/spike/src/result.ts | 1 + packages/spike/src/strategies/otp.ts | 70 ++++++++++++++++++++---- packages/spike/src/strategies/passkey.ts | 2 +- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 61a5265..7b16881 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -3,7 +3,6 @@ import { makeOTP, makePasskey, makeSignedSessionManager, - type Challenge, } from "./src/index"; import { makeTable } from "./spike-helpers"; @@ -12,8 +11,15 @@ import { makeTable } from "./spike-helpers"; */ const sessionsTable = makeTable<{ userId: string }>(); -const otpsTable = makeTable<{ identifier: string; otp: string }>(); -const challengesTable = makeTable(); +const otpsTable = makeTable<{ + identifier: string; + otp: string; + expiresAt: number; + attemptsLeft: number; +}>(); +const challengesTable = makeTable< + { purpose: "register"; handle: string } | { purpose: "authenticate" } +>(); const credentialsTable = makeTable<{ publicKey: string; handle: string }>(); // The fake authenticator in the browser. Credential id to its key and handle. @@ -38,6 +44,8 @@ const emailOtp = makeOTP({ console.log("email to:", identifier, "otp:", otp); delivered.set(identifier, otp); }, + ttl: 10 * 60 * 1000, + attempts: 3, }); const smsOtp = makeOTP({ @@ -49,6 +57,8 @@ const smsOtp = makeOTP({ console.log("sms to:", identifier, "otp:", otp); delivered.set(identifier, otp); }, + ttl: 10 * 60 * 1000, + attempts: 3, }); const passkey = makePasskey({ @@ -197,7 +207,7 @@ console.log( ); console.log( "USED", - await emailOtp.verify({ ticket: wrongTicket, otp: "nope" }), + await emailOtp.verify({ ticket: emailTicket, otp: "nope" }), ); console.log( "STRANGER", diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts index 19a1308..8efb2b1 100644 --- a/packages/spike/src/index.ts +++ b/packages/spike/src/index.ts @@ -1,4 +1,4 @@ export { makeOpaqueSessionManager } from "./sessions/opaque"; export { makeSignedSessionManager } from "./sessions/signed"; export { makeOTP } from "./strategies/otp"; -export { makePasskey, type Challenge } from "./strategies/passkey"; +export { makePasskey } from "./strategies/passkey"; diff --git a/packages/spike/src/result.ts b/packages/spike/src/result.ts index 4081ee5..2a97f3f 100644 --- a/packages/spike/src/result.ts +++ b/packages/spike/src/result.ts @@ -4,6 +4,7 @@ */ type Reason = | "unknown_ticket" + | "expired_otp" | "wrong_otp" | "unknown_challenge" | "unknown_credential" diff --git a/packages/spike/src/strategies/otp.ts b/packages/spike/src/strategies/otp.ts index 546c5bc..5713ab4 100644 --- a/packages/spike/src/strategies/otp.ts +++ b/packages/spike/src/strategies/otp.ts @@ -1,24 +1,43 @@ import { fail, succeed, type Result } from "../result"; import { issueProof, type Proof } from "../proof"; +/** What the app stores for one otp request */ +type OtpRow = { + identifier: string; + otp: string; + /** When the otp stops working, in ms since the epoch */ + expiresAt: number; + /** How many guesses the ticket still allows */ + attemptsLeft: number; +}; + export function makeOTP(args: { - /** Stores an otp row under the ticket */ - store: ( - ticket: string, - row: { identifier: string; otp: string }, - ) => Promise; + /** + * Stores an otp row under the ticket. Called on send, and again after a + * wrong guess to put back the row that take removed. + */ + store: (ticket: string, row: OtpRow) => Promise; /** Removes the otp row for a ticket and returns it, atomically. Null when there is none. */ - take: (ticket: string) => Promise<{ identifier: string; otp: string } | null>; + take: (ticket: string) => Promise; /** Delivers the otp to the identifier, an email address or a phone number */ send: (identifier: string, otp: string) => Promise; + /** Lifetime of an otp in ms */ + ttl: number; + /** How many guesses one ticket allows */ + attempts: number; }) { return { /** Returns the ticket the requesting client holds until it verifies */ send: async (identifier: string) => { const ticket = crypto.randomUUID(); - const otp = crypto.randomUUID(); + const otp = sixDigits(); - await args.store(ticket, { identifier, otp }); + await args.store(ticket, { + identifier, + otp, + expiresAt: Date.now() + args.ttl, + attemptsLeft: args.attempts, + }); await args.send(identifier, otp); return ticket; @@ -31,14 +50,45 @@ export function makeOTP(args: { ticket: string; otp: string; }): Promise< - Result, "unknown_ticket" | "wrong_otp"> + Result< + Proof<{ identifier: string }>, + "unknown_ticket" | "expired_otp" | "wrong_otp" + > > => { + // The row is out of the table while it is checked, so guesses made at + // the same time cannot get past the limit const row = await args.take(ticket); if (row === null) return fail("unknown_ticket"); - if (row.otp !== otp) return fail("wrong_otp"); + if (row.expiresAt <= Date.now()) return fail("expired_otp"); + + if (row.otp !== otp) { + if (row.attemptsLeft > 1) { + await args.store(ticket, { + ...row, + attemptsLeft: row.attemptsLeft - 1, + }); + } + + return fail("wrong_otp"); + } return succeed(issueProof({ identifier: row.identifier })); }, }; } + +/** Six random digits, every value as likely as any other */ +function sixDigits() { + const bytes = new Uint8Array(4); + const view = new DataView(bytes.buffer); + + // 4 294 000 000 is the largest multiple of a million that fits in 32 bits. + // A draw at or above it is thrown away, or the low values would come up + // more often. + do { + crypto.getRandomValues(bytes); + } while (view.getUint32(0) >= 4_294_000_000); + + return String(view.getUint32(0) % 1_000_000).padStart(6, "0"); +} diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts index 241252b..e357263 100644 --- a/packages/spike/src/strategies/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -9,7 +9,7 @@ import { issueProof, type Proof } from "../proof"; */ /** A registration challenge carries the handle until the ceremony finishes */ -export type Challenge = +type Challenge = { purpose: "register"; handle: string } | { purpose: "authenticate" }; export function makePasskey(args: { From 8851a2e9631c2e1cb6d0f40eeb0e75e9f55827e6 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 16:00:59 -0700 Subject: [PATCH 31/43] Simplify session managers, add expiry --- packages/spike/README.md | 1 + packages/spike/spike-no-kernel.ts | 45 +++++++++++-------------- packages/spike/src/index.typecheck.ts | 4 +-- packages/spike/src/proof.ts | 8 ++--- packages/spike/src/proof.typecheck.ts | 2 +- packages/spike/src/sessions/contract.ts | 15 --------- packages/spike/src/sessions/opaque.ts | 41 +++++++++++++++------- packages/spike/src/sessions/signed.ts | 11 +++--- 8 files changed, 57 insertions(+), 70 deletions(-) delete mode 100644 packages/spike/src/sessions/contract.ts diff --git a/packages/spike/README.md b/packages/spike/README.md index eff0778..bcfda14 100644 --- a/packages/spike/README.md +++ b/packages/spike/README.md @@ -41,3 +41,4 @@ Ticket is the only option where an OTP is bound to the client that asked and whe ## Revisit - Opening the library to bespoke strategies and session managers, by exporting `issueProof` and `consumeProof` or a `makeStrategy` factory around them. Either hands the shortcut back, so only on real demand. +- Type contracts. For now the types are inferred from the functions, which shows what is needed and what is not. Once the shapes settle, a new strategy or session manager starts from its contract type, as in `const makeThing: Contract = (args) => {}`. diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 7b16881..be3ba3a 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -10,7 +10,7 @@ import { makeTable } from "./spike-helpers"; * App */ -const sessionsTable = makeTable<{ userId: string }>(); +const sessionsTable = makeTable<{ userId: string; expiresAt: number }>(); const otpsTable = makeTable<{ identifier: string; otp: string; @@ -33,6 +33,7 @@ const opaque = makeOpaqueSessionManager<{ userId: string }>({ await sessionsTable.put(token, row); }, get: (token) => sessionsTable.get(token), + ttl: 30 * 24 * 60 * 60 * 1000, }); const emailOtp = makeOTP({ @@ -89,12 +90,9 @@ const emailProof = await emailOtp.verify({ }); if (!emailProof.success) throw new Error(emailProof.error); -const emailSessionId = await opaque.make( - emailProof.data, - async ({ identifier }) => ({ - userId: `user-for-${identifier}`, - }), -); +const emailSessionId = await opaque.make(emailProof.data, { + userId: `user-for-${emailProof.data.proven.identifier}`, +}); console.log("SESSION", await opaque.get(emailSessionId)); // SMS. Same three steps, other instance @@ -106,12 +104,9 @@ const smsProof = await smsOtp.verify({ }); if (!smsProof.success) throw new Error(smsProof.error); -const smsSessionId = await opaque.make( - smsProof.data, - async ({ identifier }) => ({ - userId: `user-for-${identifier}`, - }), -); +const smsSessionId = await opaque.make(smsProof.data, { + userId: `user-for-${smsProof.data.proven.identifier}`, +}); console.log("SESSION", await opaque.get(smsSessionId)); // Passkey sign-up. 1. app creates the user, 2. begin with its id as the @@ -132,10 +127,9 @@ const registered = await passkey.finishRegistration({ if (!registered.success) throw new Error(registered.error); authenticator.set(newCredentialId, { key: newKey, handle: signUp.user.id }); -const signUpSessionId = await opaque.make( - registered.data, - async ({ userHandle }) => ({ userId: userHandle }), -); +const signUpSessionId = await opaque.make(registered.data, { + userId: registered.data.proven.userHandle, +}); console.log("SESSION", await opaque.get(signUpSessionId)); // Passkey sign-in. 1. begin, 2. browser signs, 3. finish, 4. session @@ -152,10 +146,9 @@ const authenticated = await passkey.finishAuthentication({ }); if (!authenticated.success) throw new Error(authenticated.error); -const signInSessionId = await opaque.make( - authenticated.data, - async ({ userHandle }) => ({ userId: userHandle }), -); +const signInSessionId = await opaque.make(authenticated.data, { + userId: authenticated.data.proven.userHandle, +}); console.log("SESSION", await opaque.get(signInSessionId)); // Add a passkey while signed in. The session says who, its user id is the @@ -192,9 +185,9 @@ const signedProof = await emailOtp.verify({ }); if (!signedProof.success) throw new Error(signedProof.error); -const token = await signed.make(signedProof.data, async ({ identifier }) => ({ - userId: `user-for-${identifier}`, -})); +const token = await signed.make(signedProof.data, { + userId: `user-for-${signedProof.data.proven.identifier}`, +}); console.log("TOKEN", token); console.log("SIGNED SESSION", await signed.get(token)); console.log("TAMPERED", await signed.get(`${token.slice(0, -2)}xx`)); @@ -221,7 +214,7 @@ console.log( // Reuse the proof. Rejected at runtime try { - await opaque.make(emailProof.data, async () => ({ userId: "someone-else" })); + await opaque.make(emailProof.data, { userId: "someone-else" }); } catch (error) { console.log("REUSE", error instanceof Error ? error.message : error); } @@ -229,5 +222,5 @@ try { // The guard. Never called, it exists to show what does not compile. export function withoutProof(userId: string) { // @ts-expect-error a session cannot be made without a proof - return opaque.make({ userId }, async () => ({ userId })); + return opaque.make({ userId }, { userId }); } diff --git a/packages/spike/src/index.typecheck.ts b/packages/spike/src/index.typecheck.ts index ed42ba6..39fc0aa 100644 --- a/packages/spike/src/index.typecheck.ts +++ b/packages/spike/src/index.typecheck.ts @@ -10,12 +10,10 @@ import { issueProof } from "./index"; import { consumeProof } from "./index"; // @ts-expect-error failures are made by strategies, the app only reads them import { fail } from "./index"; -// @ts-expect-error the contract is for the library's own session managers -import type { SessionManager } from "./index"; // @ts-expect-error an app never has to name a proof import type { Proof } from "./index"; // @ts-expect-error an app never has to name a failure import type { Failure } from "./index"; export const absent = [issueProof, consumeProof, fail]; -export type Absent = [SessionManager, Proof, Failure]; +export type Absent = [Proof, Failure]; diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts index 3f7d247..38083ed 100644 --- a/packages/spike/src/proof.ts +++ b/packages/spike/src/proof.ts @@ -26,13 +26,11 @@ export function issueProof(proven: T): Proof { } /** - * A session manager starts by calling this. Returns what was proven. Throws - * for a proof that was never issued or was already consumed. + * A session manager starts by calling this. Throws for a proof that was + * never issued or was already consumed. */ -export function consumeProof(proof: Proof): T { +export function consumeProof(proof: Proof): void { if (!issued.delete(proof)) { throw new Error("not a proof, or already consumed"); } - - return proof.proven; } diff --git a/packages/spike/src/proof.typecheck.ts b/packages/spike/src/proof.typecheck.ts index c4d4cab..3812b1f 100644 --- a/packages/spike/src/proof.typecheck.ts +++ b/packages/spike/src/proof.typecheck.ts @@ -18,5 +18,5 @@ void written; void sessionManager.make( // @ts-expect-error a session cannot be made without a proof { proven: { identifier: "victim@example.com" } }, - async () => ({ userId: "victim" }), + { userId: "victim" }, ); diff --git a/packages/spike/src/sessions/contract.ts b/packages/spike/src/sessions/contract.ts deleted file mode 100644 index ebfbd81..0000000 --- a/packages/spike/src/sessions/contract.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Proof } from "../proof"; - -/** - * What every session manager provides. make spends a proof and - * returns a token, get turns a token back into the session or null. Anything - * else a session manager can do, such as ending a session, is its own method - * beside these two. Session is what the app decides a session is. - */ -export type SessionManager = { - make: ( - proof: Proof, - resolve: (proven: T) => Promise, - ) => Promise; - get: (token: string) => Promise; -}; diff --git a/packages/spike/src/sessions/opaque.ts b/packages/spike/src/sessions/opaque.ts index bbc498e..180ba0b 100644 --- a/packages/spike/src/sessions/opaque.ts +++ b/packages/spike/src/sessions/opaque.ts @@ -1,26 +1,41 @@ import { consumeProof, type Proof } from "../proof"; -import type { SessionManager } from "./contract"; -/** The token is a random string the row is stored under. Ending a session is deleting the row. */ +/** + * The token is a random string the row is stored under. Ending a session is + * deleting the row. The row is the session with expiresAt added, so a session + * has no expiresAt of its own. + */ export function makeOpaqueSessionManager(args: { - /** Stores a session row under the token */ - store: (token: string, row: Session) => Promise; + /** Stores a session row under the token. expiresAt is in ms since the epoch. */ + store: (token: string, row: Session & { expiresAt: number }) => Promise; /** Reads the session row for a token, null when there is none */ - get: (token: string) => Promise; + get: (token: string) => Promise<(Session & { expiresAt: number }) | null>; + /** Lifetime of a session in ms */ + ttl: number; }) { return { - make: async ( - proof: Proof, - resolve: (proven: T) => Promise, - ) => { - const session = await resolve(consumeProof(proof)); + /** Spends the proof and returns the token the session is stored under */ + make: async (proof: Proof, session: Session) => { + consumeProof(proof); + const token = crypto.randomUUID(); - await args.store(token, session); + await args.store(token, { + ...session, + expiresAt: Date.now() + args.ttl, + }); return token; }, - get: (token: string) => args.get(token), - } satisfies SessionManager; + /** Null when there is no session for the token or it has expired */ + get: async (token: string): Promise => { + const row = await args.get(token); + + if (row === null) return null; + if (row.expiresAt <= Date.now()) return null; + + return row; + }, + }; } diff --git a/packages/spike/src/sessions/signed.ts b/packages/spike/src/sessions/signed.ts index 3e7f8f9..ee19efd 100644 --- a/packages/spike/src/sessions/signed.ts +++ b/packages/spike/src/sessions/signed.ts @@ -1,5 +1,4 @@ import { consumeProof, type Proof } from "../proof"; -import type { SessionManager } from "./contract"; /** * Stateless. The session travels inside the token, signed so it cannot be @@ -21,11 +20,9 @@ export function makeSignedSessionManager(args: { ); return { - make: async ( - proof: Proof, - resolve: (proven: T) => Promise, - ) => { - const session = await resolve(consumeProof(proof)); + make: async (proof: Proof, session: Session) => { + consumeProof(proof); + const payload = encode( JSON.stringify({ session, exp: Date.now() + args.ttl }), ); @@ -61,7 +58,7 @@ export function makeSignedSessionManager(args: { return session; }, - } satisfies SessionManager; + }; } function bytes(value: string) { From 259908d02a25677f1a1f1d4d4b99f2f0fcc9d08b Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 16:17:27 -0700 Subject: [PATCH 32/43] Add session end to spike --- packages/spike/spike-no-kernel.ts | 3 +++ packages/spike/src/sessions/opaque.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index be3ba3a..d754179 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -33,6 +33,9 @@ const opaque = makeOpaqueSessionManager<{ userId: string }>({ await sessionsTable.put(token, row); }, get: (token) => sessionsTable.get(token), + delete: async (token) => { + await sessionsTable.delete(token); + }, ttl: 30 * 24 * 60 * 60 * 1000, }); diff --git a/packages/spike/src/sessions/opaque.ts b/packages/spike/src/sessions/opaque.ts index 180ba0b..4ec7bb7 100644 --- a/packages/spike/src/sessions/opaque.ts +++ b/packages/spike/src/sessions/opaque.ts @@ -1,15 +1,16 @@ import { consumeProof, type Proof } from "../proof"; /** - * The token is a random string the row is stored under. Ending a session is - * deleting the row. The row is the session with expiresAt added, so a session - * has no expiresAt of its own. + * The token is a random string the row is stored under. The row is the + * session with expiresAt added, so a session has no expiresAt of its own. */ export function makeOpaqueSessionManager(args: { /** Stores a session row under the token. expiresAt is in ms since the epoch. */ store: (token: string, row: Session & { expiresAt: number }) => Promise; /** Reads the session row for a token, null when there is none */ get: (token: string) => Promise<(Session & { expiresAt: number }) | null>; + /** Removes the session row for a token. Does nothing when there is none. */ + delete: (token: string) => Promise; /** Lifetime of a session in ms */ ttl: number; }) { @@ -37,5 +38,10 @@ export function makeOpaqueSessionManager(args: { return row; }, + + /** Ends the session. The token stops working at once. */ + end: async (token: string) => { + await args.delete(token); + }, }; } From 6637a701ea5cb9486f6f0e4966909c6e04e309ee Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 16:17:27 -0700 Subject: [PATCH 33/43] Rewire OTP example to spike --- bun.lock | 1 + .../otp-memory/package.json | 1 + .../otp-memory/src/auth-rpc.ts | 47 ++++++++---- .../otp-memory/src/auth.ts | 73 ++++++++++++------- .../tanstack-start-react/otp-memory/src/db.ts | 34 ++++++--- .../otp-memory/src/routes/index.tsx | 22 +++--- .../otp-memory/src/session-cookie.ts | 5 +- 7 files changed, 120 insertions(+), 63 deletions(-) diff --git a/bun.lock b/bun.lock index bb846fb..d9e4d47 100644 --- a/bun.lock +++ b/bun.lock @@ -113,6 +113,7 @@ "name": "@repo/example-tanstack-otp", "dependencies": { "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.39", "@tanstack/react-start": "^1.168.58", diff --git a/examples/tanstack-start-react/otp-memory/package.json b/examples/tanstack-start-react/otp-memory/package.json index 395c7f6..a3e9c11 100644 --- a/examples/tanstack-start-react/otp-memory/package.json +++ b/examples/tanstack-start-react/otp-memory/package.json @@ -12,6 +12,7 @@ "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", diff --git a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts b/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts index 826a1e2..1b9d505 100644 --- a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts @@ -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 */ @@ -15,16 +22,21 @@ 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 @@ -36,13 +48,17 @@ export const requestOtp = createServerFn({ method: "POST" }) 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 user = db.users.upsert(result.data.proven.identifier); + + sessionCookie.set( + await sessionManager.make(result.data, { userId: user.userId }), + ); - return { success: true, isNew: result.data.user.isNew }; + return { success: true, isNew: user.isNew }; }); /** @@ -54,13 +70,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 = db.users.updateEmail( + identity.userId, + verified.data.proven.identifier, + ); if (!user) return { success: false }; return { success: true, viewer: user }; @@ -72,7 +91,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(); }); @@ -83,7 +104,7 @@ export const signOut = createServerFn({ method: "POST" }).handler(async () => { */ export const signOutAll = createServerFn({ method: "POST" }).handler( async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (identity) db.sessions.deleteAllForUser(identity.userId); sessionCookie.clear(); }, @@ -95,7 +116,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; }); diff --git a/examples/tanstack-start-react/otp-memory/src/auth.ts b/examples/tanstack-start-react/otp-memory/src/auth.ts index 05f9ba1..3851bcb 100644 --- a/examples/tanstack-start-react/otp-memory/src/auth.ts +++ b/examples/tanstack-start-react/otp-memory/src/auth.ts @@ -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) }; - }, - }), -})); diff --git a/examples/tanstack-start-react/otp-memory/src/db.ts b/examples/tanstack-start-react/otp-memory/src/db.ts index 5411432..3fcaae8 100644 --- a/examples/tanstack-start-react/otp-memory/src/db.ts +++ b/examples/tanstack-start-react/otp-memory/src/db.ts @@ -4,13 +4,22 @@ * Simple in-memory stores for demonstration purposes. In a real app these * would be replaced with database queries. */ -import type { OtpRecord, SessionRecord } from "authax"; + +type SessionRow = { id: string; userId: string; expiresAt: Date }; + +type OtpRow = { + id: string; + email: string; + otp: string; + expiresAt: Date; + attemptsLeft: number; +}; const users = new Map(); let userIdCounter = 0; -const sessions = new Map(); -const otps = new Map(); +const sessions = new Map(); +const otps = new Map(); export const db = { users: { @@ -38,11 +47,11 @@ export const db = { }, sessions: { - store: async (record: SessionRecord) => { - sessions.set(record.sessionId, record); + insert: async (row: SessionRow) => { + sessions.set(row.id, row); }, - get: async (sessionId: string) => sessions.get(sessionId) ?? null, + get: async (id: string) => sessions.get(id) ?? null, delete: async (sessionId: string) => { sessions.delete(sessionId); @@ -58,14 +67,15 @@ export const db = { }, otps: { - store: async (record: OtpRecord) => { - otps.set(record.identifier, record); + insert: async (row: OtpRow) => { + otps.set(row.id, row); }, - take: async (identifier: string) => { - const record = otps.get(identifier) ?? null; - otps.delete(identifier); - return record; + /** Deletes the row and returns it, null when there is none */ + delete: async (id: string) => { + const row = otps.get(id) ?? null; + otps.delete(id); + return row; }, }, }; diff --git a/examples/tanstack-start-react/otp-memory/src/routes/index.tsx b/examples/tanstack-start-react/otp-memory/src/routes/index.tsx index e5f5f54..7b4c298 100644 --- a/examples/tanstack-start-react/otp-memory/src/routes/index.tsx +++ b/examples/tanstack-start-react/otp-memory/src/routes/index.tsx @@ -30,6 +30,7 @@ type Viewer = { userId: string; email: string }; function AuthFlow(props: { onSignedIn: () => void }) { const [step, setStep] = useState<"email" | "otp">("email"); const [email, setEmail] = useState(""); + const [ticket, setTicket] = useState(""); const [otp, setOtp] = useState(""); const [error, setError] = useState(null); @@ -41,6 +42,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { e.preventDefault(); const result = await requestOtp({ data: { identifier: email } }); if (result.success) { + setTicket(result.ticket); setStep("otp"); setError(null); } else { @@ -66,7 +68,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { onSubmit={async (e) => { e.preventDefault(); const result = await verifyOtp({ - data: { identifier: email, otp }, + data: { ticket, otp }, }); if (result.success) { props.onSignedIn(); @@ -82,9 +84,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { @@ -92,7 +92,8 @@ function AuthFlow(props: { onSignedIn: () => void }) { variant="secondary" type="button" onClick={async () => { - await requestOtp({ data: { identifier: email } }); + const result = await requestOtp({ data: { identifier: email } }); + setTicket(result.ticket); setOtp(""); setError(null); }} @@ -106,6 +107,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { const [step, setStep] = useState<"email" | "otp">("email"); const [email, setEmail] = useState(""); + const [ticket, setTicket] = useState(""); const [otp, setOtp] = useState(""); const [error, setError] = useState(null); @@ -117,6 +119,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { e.preventDefault(); const result = await requestOtp({ data: { identifier: email } }); if (result.success) { + setTicket(result.ticket); setStep("otp"); setError(null); } else { @@ -148,7 +151,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { onSubmit={async (e) => { e.preventDefault(); const result = await changeEmail({ - data: { identifier: email, otp }, + data: { ticket, otp }, }); if (result.success) { props.onDone(); @@ -164,9 +167,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { @@ -174,7 +175,8 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { variant="secondary" type="button" onClick={async () => { - await requestOtp({ data: { identifier: email } }); + const result = await requestOtp({ data: { identifier: email } }); + setTicket(result.ticket); setOtp(""); setError(null); }} diff --git a/examples/tanstack-start-react/otp-memory/src/session-cookie.ts b/examples/tanstack-start-react/otp-memory/src/session-cookie.ts index 60eb30c..b09ec3f 100644 --- a/examples/tanstack-start-react/otp-memory/src/session-cookie.ts +++ b/examples/tanstack-start-react/otp-memory/src/session-cookie.ts @@ -1,4 +1,5 @@ import { getCookie, setCookie } from "@tanstack/react-start/server"; +import { sessionTtl } from "./auth"; const name = "session"; @@ -12,7 +13,7 @@ const options = { /** Moves the session token between the request and the auth API */ export const sessionCookie = { get: () => getCookie(name) ?? null, - set: (token: string, expiresAt: Date) => - setCookie(name, token, { ...options, expires: expiresAt }), + set: (token: string) => + setCookie(name, token, { ...options, maxAge: sessionTtl / 1000 }), clear: () => setCookie(name, "", { ...options, maxAge: 0 }), }; From 7e1f7f8e64ffb0c03f64bb86355775592c12660a Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 16:20:10 -0700 Subject: [PATCH 34/43] Remove authax from OTP example --- bun.lock | 1 - examples/tanstack-start-react/otp-memory/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/bun.lock b/bun.lock index d9e4d47..26b31b4 100644 --- a/bun.lock +++ b/bun.lock @@ -117,7 +117,6 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.39", "@tanstack/react-start": "^1.168.58", - "authax": "workspace:*", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", diff --git a/examples/tanstack-start-react/otp-memory/package.json b/examples/tanstack-start-react/otp-memory/package.json index a3e9c11..139d087 100644 --- a/examples/tanstack-start-react/otp-memory/package.json +++ b/examples/tanstack-start-react/otp-memory/package.json @@ -10,7 +10,6 @@ "typecheck": "tsc" }, "dependencies": { - "authax": "workspace:*", "@repo/shared-react": "workspace:*", "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", From beb4c28db16592c523188b3f46eeb689bd28947c Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 16:33:37 -0700 Subject: [PATCH 35/43] Move table helper to demo --- packages/spike/package.json | 3 ++- packages/spike/spike-no-kernel.ts | 2 +- packages/spike/spike.ts | 2 +- packages/spike/src/demo/index.ts | 1 + packages/spike/{spike-helpers.ts => src/demo/memory-table.ts} | 0 5 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 packages/spike/src/demo/index.ts rename packages/spike/{spike-helpers.ts => src/demo/memory-table.ts} (100%) diff --git a/packages/spike/package.json b/packages/spike/package.json index a0d32e6..7e517b0 100644 --- a/packages/spike/package.json +++ b/packages/spike/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./demo": "./src/demo/index.ts" }, "scripts": { "typecheck": "tsc" diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index d754179..2b1f18f 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -4,7 +4,7 @@ import { makePasskey, makeSignedSessionManager, } from "./src/index"; -import { makeTable } from "./spike-helpers"; +import { makeTable } from "./src/demo/index"; /** * App diff --git a/packages/spike/spike.ts b/packages/spike/spike.ts index bc7e2ab..3c5bffb 100644 --- a/packages/spike/spike.ts +++ b/packages/spike/spike.ts @@ -1,4 +1,4 @@ -import { makeTable } from "./spike-helpers"; +import { makeTable } from "./src/demo/index"; const _email = "ripley@example.com"; let _interceptedOneTimePasscode = ""; diff --git a/packages/spike/src/demo/index.ts b/packages/spike/src/demo/index.ts new file mode 100644 index 0000000..a0baafd --- /dev/null +++ b/packages/spike/src/demo/index.ts @@ -0,0 +1 @@ +export { makeTable } from "./memory-table"; diff --git a/packages/spike/spike-helpers.ts b/packages/spike/src/demo/memory-table.ts similarity index 100% rename from packages/spike/spike-helpers.ts rename to packages/spike/src/demo/memory-table.ts From fac4e3b8485bcf746e6229c3bb62e78b881d39a2 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 16:38:33 -0700 Subject: [PATCH 36/43] Reshape memory table helper --- packages/spike/spike-no-kernel.ts | 37 +++++++++----- packages/spike/spike.ts | 21 +++++--- packages/spike/src/demo/index.ts | 2 +- packages/spike/src/demo/memory-table.ts | 66 +++++++++++++++---------- 4 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 2b1f18f..124c763 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -4,23 +4,34 @@ import { makePasskey, makeSignedSessionManager, } from "./src/index"; -import { makeTable } from "./src/demo/index"; +import { makeMemoryTable } from "./src/demo/index"; /** * App */ -const sessionsTable = makeTable<{ userId: string; expiresAt: number }>(); -const otpsTable = makeTable<{ +const sessionsTable = makeMemoryTable<{ + token: string; + userId: string; + expiresAt: number; +}>("token"); +const otpsTable = makeMemoryTable<{ + ticket: string; identifier: string; otp: string; expiresAt: number; attemptsLeft: number; -}>(); -const challengesTable = makeTable< - { purpose: "register"; handle: string } | { purpose: "authenticate" } ->(); -const credentialsTable = makeTable<{ publicKey: string; handle: string }>(); +}>("ticket"); +const challengesTable = makeMemoryTable< + { challenge: string } & ( + { purpose: "register"; handle: string } | { purpose: "authenticate" } + ) +>("challenge"); +const credentialsTable = makeMemoryTable<{ + credentialId: string; + publicKey: string; + handle: string; +}>("credentialId"); // The fake authenticator in the browser. Credential id to its key and handle. const authenticator = new Map(); @@ -30,7 +41,7 @@ const delivered = new Map(); const opaque = makeOpaqueSessionManager<{ userId: string }>({ store: async (token, row) => { - await sessionsTable.put(token, row); + await sessionsTable.insert({ token, ...row }); }, get: (token) => sessionsTable.get(token), delete: async (token) => { @@ -41,7 +52,7 @@ const opaque = makeOpaqueSessionManager<{ userId: string }>({ const emailOtp = makeOTP({ store: async (ticket, row) => { - await otpsTable.put(ticket, row); + await otpsTable.insert({ ticket, ...row }); }, take: (ticket) => otpsTable.delete(ticket), send: async (identifier, otp) => { @@ -54,7 +65,7 @@ const emailOtp = makeOTP({ const smsOtp = makeOTP({ store: async (ticket, row) => { - await otpsTable.put(ticket, row); + await otpsTable.insert({ ticket, ...row }); }, take: (ticket) => otpsTable.delete(ticket), send: async (identifier, otp) => { @@ -69,11 +80,11 @@ const passkey = makePasskey({ rpId: "localhost", rpName: "Spike", storeChallenge: async (challenge, row) => { - await challengesTable.put(challenge, row); + await challengesTable.insert({ challenge, ...row }); }, takeChallenge: (challenge) => challengesTable.delete(challenge), storeCredential: async (credentialId, row) => { - await credentialsTable.put(credentialId, row); + await credentialsTable.insert({ credentialId, ...row }); }, getCredential: (credentialId) => credentialsTable.get(credentialId), }); diff --git a/packages/spike/spike.ts b/packages/spike/spike.ts index 3c5bffb..1ed63cd 100644 --- a/packages/spike/spike.ts +++ b/packages/spike/spike.ts @@ -1,16 +1,18 @@ -import { makeTable } from "./src/demo/index"; +import { makeMemoryTable } from "./src/demo/index"; const _email = "ripley@example.com"; let _interceptedOneTimePasscode = ""; // The app's own table. Keyed by a random id, email is a column. -const usersTable = makeTable<{ email: string }>(); +const usersTable = makeMemoryTable<{ id: string; email: string }>("id"); // Opaque session. Remembers the user id under a random id. -const sessionsTable = makeTable<{ userId: string }>(); +const sessionsTable = makeMemoryTable<{ id: string; userId: string }>("id"); // OTP. One row per sent otp, keyed by a random id. Checked once. -const otpsTable = makeTable<{ email: string; otp: string }>(); +const otpsTable = makeMemoryTable<{ id: string; email: string; otp: string }>( + "id", +); /** * Session @@ -20,7 +22,8 @@ const opaque = { make: async ({ userId }: { userId: string }) => { console.log("making session", userId); - const { id } = await sessionsTable.insert({ userId }); + const id = crypto.randomUUID(); + await sessionsTable.insert({ id, userId }); console.log("session made", id, userId); return id; @@ -40,7 +43,8 @@ const otp = { send: async (email: string) => { console.log("sending", email); const otp = crypto.randomUUID(); - const { id } = await otpsTable.insert({ email, otp }); + const id = crypto.randomUUID(); + await otpsTable.insert({ id, email, otp }); // Capture the otp for demo _interceptedOneTimePasscode = otp; @@ -93,7 +97,10 @@ console.log("-".repeat(80)); const otpId = await otp.send(_email); export const signIn = core(otp.verify, async ({ email }) => { - const user = await usersTable.upsert("email", { email }); + const [existing] = await usersTable.where({ email }); + const user = existing ?? { id: crypto.randomUUID(), email }; + if (existing === undefined) await usersTable.insert(user); + return opaque.make({ userId: user.id }); }); diff --git a/packages/spike/src/demo/index.ts b/packages/spike/src/demo/index.ts index a0baafd..b9d0f03 100644 --- a/packages/spike/src/demo/index.ts +++ b/packages/spike/src/demo/index.ts @@ -1 +1 @@ -export { makeTable } from "./memory-table"; +export { makeMemoryTable } from "./memory-table"; diff --git a/packages/spike/src/demo/memory-table.ts b/packages/spike/src/demo/memory-table.ts index 58143af..0f1e5c6 100644 --- a/packages/spike/src/demo/memory-table.ts +++ b/packages/spike/src/demo/memory-table.ts @@ -1,35 +1,51 @@ -export function makeTable() { - const data = new Map(); +/** The fields of a row that hold a string, the only ones that can be its key */ +type StringField = { + [Field in keyof Row]: Row[Field] extends string ? Field : never; +}[keyof Row]; - const insert = (row: V) => { - const record = { ...row, id: crypto.randomUUID() }; - data.set(record.id, record); - return record; - }; +/** + * For demos only. Rows live in memory and are lost on restart. Every function + * is async, so the code that calls it reads as it would against a database. + * key names the field that holds the key of a row. + */ +export function makeMemoryTable( + key: StringField & keyof Row, +) { + const rows = new Map(); return { - get: async (id: string) => data.get(id) ?? null, - insert: async (row: V) => insert(row), - /** Insert with a caller supplied id */ - put: async (id: string, row: V) => { - const record = { ...row, id }; - data.set(id, record); - return record; + /** Stores a new row under its key. Throws when the key is taken. */ + insert: async (row: Row) => { + const id = String(row[key]); + + if (rows.has(id)) throw new Error("a row with this key already exists"); + + rows.set(id, { ...row }); }, - upsert: async (key: keyof V, row: V) => { - for (const [id, current] of data) { - if (current[key] === row[key]) { - const record = { ...row, id }; - data.set(id, record); - return record; - } - } - return insert(row); + + /** The row for a key, null when there is none */ + get: async (id: string) => { + const row = rows.get(id); + + return row === undefined ? null : { ...row }; }, + + /** Removes the row for a key and returns it, null when there is none */ delete: async (id: string) => { - const row = data.get(id) ?? null; - data.delete(id); + const row = rows.get(id) ?? null; + rows.delete(id); + return row; }, + + /** Every row whose fields equal the ones given */ + where: async (match: Partial) => + [...rows.values()] + .filter((row) => + Object.entries(match).every( + ([field, value]) => row[field as keyof Row] === value, + ), + ) + .map((row) => ({ ...row })), }; } From f48af744ba68878989478ae4394bf5a05ee2947f Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 17:01:49 -0700 Subject: [PATCH 37/43] Add update and findOrInsert --- packages/spike/src/demo/memory-table.ts | 63 +++++++++++++++++++------ 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/spike/src/demo/memory-table.ts b/packages/spike/src/demo/memory-table.ts index 0f1e5c6..69eb4f5 100644 --- a/packages/spike/src/demo/memory-table.ts +++ b/packages/spike/src/demo/memory-table.ts @@ -13,15 +13,26 @@ export function makeMemoryTable( ) { const rows = new Map(); - return { - /** Stores a new row under its key. Throws when the key is taken. */ - insert: async (row: Row) => { - const id = String(row[key]); + const insert = (row: Row) => { + const id = String(row[key]); - if (rows.has(id)) throw new Error("a row with this key already exists"); + if (rows.has(id)) throw new Error("a row with this key already exists"); - rows.set(id, { ...row }); - }, + rows.set(id, { ...row }); + }; + + const where = (match: Partial) => + [...rows.values()] + .filter((row) => + Object.entries(match).every( + ([field, value]) => row[field as keyof Row] === value, + ), + ) + .map((row) => ({ ...row })); + + return { + /** Stores a new row under its key. Throws when the key is taken. */ + insert: async (row: Row) => insert(row), /** The row for a key, null when there is none */ get: async (id: string) => { @@ -30,6 +41,21 @@ export function makeMemoryTable( return row === undefined ? null : { ...row }; }, + /** + * Changes fields of the row for a key and returns the row, null when + * there is none. The key itself cannot change. + */ + update: async (id: string, fields: Partial) => { + const row = rows.get(id); + + if (row === undefined) return null; + if (key in fields) throw new Error("the key of a row cannot change"); + + rows.set(id, { ...row, ...fields }); + + return { ...row, ...fields }; + }, + /** Removes the row for a key and returns it, null when there is none */ delete: async (id: string) => { const row = rows.get(id) ?? null; @@ -39,13 +65,20 @@ export function makeMemoryTable( }, /** Every row whose fields equal the ones given */ - where: async (match: Partial) => - [...rows.values()] - .filter((row) => - Object.entries(match).every( - ([field, value]) => row[field as keyof Row] === value, - ), - ) - .map((row) => ({ ...row })), + where: async (match: Partial) => where(match), + + /** + * The first row whose fields equal the ones in match. When there is none, + * row is inserted and returned. isNew tells which of the two happened. + */ + findOrInsert: async (match: Partial, row: Row) => { + const [found] = where(match); + + if (found !== undefined) return { row: found, isNew: false }; + + insert(row); + + return { row: { ...row }, isNew: true }; + }, }; } From b603e27b15c83bf0ea17ac0bd7ab34e608feb35d Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 17:01:49 -0700 Subject: [PATCH 38/43] Use memory table in example --- .../otp-memory/src/auth-rpc.ts | 16 ++-- .../tanstack-start-react/otp-memory/src/db.ts | 73 +++++-------------- 2 files changed, 28 insertions(+), 61 deletions(-) diff --git a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts b/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts index 1b9d505..260db6e 100644 --- a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts @@ -41,7 +41,7 @@ export const requestOtp = createServerFn({ method: "POST" }) /** * 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.). */ @@ -52,13 +52,17 @@ export const verifyOtp = createServerFn({ method: "POST" }) if (!result.success) return { success: false }; - const user = db.users.upsert(result.data.proven.identifier); + 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: user.isNew }; + return { success: true, isNew }; }); /** @@ -76,7 +80,7 @@ export const changeEmail = createServerFn({ method: "POST" }) const verified = await emailOtp.verify(data); if (!verified.success) return { success: false }; - const user = db.users.updateEmail( + const user = await db.users.updateEmail( identity.userId, verified.data.proven.identifier, ); @@ -105,7 +109,7 @@ export const signOut = createServerFn({ method: "POST" }).handler(async () => { export const signOutAll = createServerFn({ method: "POST" }).handler( async () => { const identity = await getIdentity(); - if (identity) db.sessions.deleteAllForUser(identity.userId); + if (identity) await db.sessions.deleteAllForUser(identity.userId); sessionCookie.clear(); }, ); @@ -118,5 +122,5 @@ export const signOutAll = createServerFn({ method: "POST" }).handler( export const getViewer = createServerFn().handler(async () => { const identity = await getIdentity(); - return identity ? (db.users.get(identity.userId) ?? null) : null; + return identity ? db.users.get(identity.userId) : null; }); diff --git a/examples/tanstack-start-react/otp-memory/src/db.ts b/examples/tanstack-start-react/otp-memory/src/db.ts index 3fcaae8..66315d8 100644 --- a/examples/tanstack-start-react/otp-memory/src/db.ts +++ b/examples/tanstack-start-react/otp-memory/src/db.ts @@ -4,78 +4,41 @@ * Simple in-memory stores for demonstration purposes. In a real app these * would be replaced with database queries. */ +import { makeMemoryTable } from "@repo/spike/demo"; -type SessionRow = { id: string; userId: string; expiresAt: Date }; +const users = makeMemoryTable<{ userId: string; email: string }>("userId"); -type OtpRow = { +const sessions = makeMemoryTable<{ + id: string; + userId: string; + expiresAt: Date; +}>("id"); + +const otps = makeMemoryTable<{ id: string; email: string; otp: string; expiresAt: Date; attemptsLeft: number; -}; - -const users = new Map(); -let userIdCounter = 0; - -const sessions = new Map(); -const otps = new Map(); +}>("id"); export const db = { users: { - upsert: (email: string) => { - const exists = Array.from(users.values()).find((u) => u.email === email); - - if (exists) { - return { userId: exists.userId, isNew: false }; - } - - const userId = `user_${++userIdCounter}`; - users.set(userId, { userId, email }); - - return { userId, isNew: true }; - }, + ...users, - get: (userId: string) => users.get(userId), - - updateEmail: (userId: string, email: string) => { - const user = users.get(userId); - if (!user) return undefined; - user.email = email; - return user; - }, + updateEmail: (userId: string, email: string) => + users.update(userId, { email }), }, sessions: { - insert: async (row: SessionRow) => { - sessions.set(row.id, row); - }, - - get: async (id: string) => sessions.get(id) ?? null, - - delete: async (sessionId: string) => { - sessions.delete(sessionId); - }, + ...sessions, - deleteAllForUser: (userId: string) => { - for (const [sessionId, record] of sessions) { - if (record.userId === userId) { - sessions.delete(sessionId); - } + deleteAllForUser: async (userId: string) => { + for (const session of await sessions.where({ userId })) { + await sessions.delete(session.id); } }, }, - otps: { - insert: async (row: OtpRow) => { - otps.set(row.id, row); - }, - - /** Deletes the row and returns it, null when there is none */ - delete: async (id: string) => { - const row = otps.get(id) ?? null; - otps.delete(id); - return row; - }, - }, + otps, }; From d6b23f846f8e9f873230ad8fb55aa21273621e46 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 17:30:09 -0700 Subject: [PATCH 39/43] Copy WebAuthn code into spike --- packages/spike/src/webauthn/cbor.ts | 102 +++++ packages/spike/src/webauthn/contracts.ts | 23 ++ packages/spike/src/webauthn/crypto.ts | 134 +++++++ packages/spike/src/webauthn/lib.ts | 24 ++ packages/spike/src/webauthn/webauthn.ts | 451 +++++++++++++++++++++++ 5 files changed, 734 insertions(+) create mode 100644 packages/spike/src/webauthn/cbor.ts create mode 100644 packages/spike/src/webauthn/contracts.ts create mode 100644 packages/spike/src/webauthn/crypto.ts create mode 100644 packages/spike/src/webauthn/lib.ts create mode 100644 packages/spike/src/webauthn/webauthn.ts diff --git a/packages/spike/src/webauthn/cbor.ts b/packages/spike/src/webauthn/cbor.ts new file mode 100644 index 0000000..04e9fe5 --- /dev/null +++ b/packages/spike/src/webauthn/cbor.ts @@ -0,0 +1,102 @@ +/** + * Minimal CBOR decoder for WebAuthn + * + * Only decodes the subset used by WebAuthn: + * - Major type 0: unsigned integer + * - Major type 1: negative integer + * - Major type 2: byte string + * - Major type 3: text string + * - Major type 4: array + * - Major type 5: map + * + * All reads are bounded by the input. + */ + +export type CborValue = + number | Uint8Array | string | CborValue[] | Map; + +export function decodeCbor(data: Uint8Array): CborValue { + let offset = 0; + + function read(n: number): Uint8Array { + if (offset + n > data.length) { + throw new Error("CBOR: input exhausted"); + } + const slice = data.subarray(offset, offset + n); + offset += n; + return slice; + } + + function readUint8(): number { + const byte = data[offset]; + if (byte === undefined) { + throw new Error("CBOR: input exhausted"); + } + offset += 1; + return byte; + } + + function readLength(additionalInfo: number): number { + if (additionalInfo < 24) return additionalInfo; + if (additionalInfo === 24) return readUint8(); + if (additionalInfo === 25) { + const bytes = read(2); + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint16(0); + } + if (additionalInfo === 26) { + const bytes = read(4); + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(0); + } + throw new Error("CBOR: unsupported length encoding"); + } + + function decode(): CborValue { + const initial = readUint8(); + const majorType = initial >> 5; + const additionalInfo = initial & 0x1f; + + switch (majorType) { + case 0: // unsigned integer + return readLength(additionalInfo); + case 1: // negative integer + return -1 - readLength(additionalInfo); + case 2: // byte string + return new Uint8Array(read(readLength(additionalInfo))); + case 3: { + // text string + const bytes = read(readLength(additionalInfo)); + return new TextDecoder().decode(bytes); + } + case 4: { + // array + const length = readLength(additionalInfo); + const arr: CborValue[] = []; + for (let i = 0; i < length; i++) arr.push(decode()); + return arr; + } + case 5: { + // map + const length = readLength(additionalInfo); + const map = new Map(); + for (let i = 0; i < length; i++) { + const key = decode(); + const value = decode(); + map.set(key, value); + } + return map; + } + default: + throw new Error(`CBOR: unsupported major type ${majorType.toString()}`); + } + } + + return decode(); +} diff --git a/packages/spike/src/webauthn/contracts.ts b/packages/spike/src/webauthn/contracts.ts new file mode 100644 index 0000000..e8837dc --- /dev/null +++ b/packages/spike/src/webauthn/contracts.ts @@ -0,0 +1,23 @@ +/** + * Passkey ceremony inputs, picked from the standard WebAuthn JSON types: + * exactly the fields verification consumes. Everything outside the signed bytes + * is unauthenticated and not accepted. The output of + * PublicKeyCredential.toJSON() satisfies these shapes. + */ +export type PasskeyRegistrationCredential = { + response: Pick< + AuthenticatorAttestationResponseJSON, + "clientDataJSON" | "attestationObject" + >; +}; + +/** See PasskeyRegistrationCredential. id locates the stored credential; the signature check binds it. */ +export type PasskeyAuthenticationCredential = Pick< + AuthenticationResponseJSON, + "id" +> & { + response: Pick< + AuthenticatorAssertionResponseJSON, + "clientDataJSON" | "authenticatorData" | "signature" + >; +}; diff --git a/packages/spike/src/webauthn/crypto.ts b/packages/spike/src/webauthn/crypto.ts new file mode 100644 index 0000000..5296ce4 --- /dev/null +++ b/packages/spike/src/webauthn/crypto.ts @@ -0,0 +1,134 @@ +import { invariant } from "./lib"; + +/** + * Crypto primitives using Web Crypto API + * + * All functions work in browsers, Node.js 18+, Bun, and Deno. + */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** Encode bytes or a UTF-8 string as unpadded base64url */ +export function base64urlEncode(data: Uint8Array | string): string { + const bytes = typeof data === "string" ? encoder.encode(data) : data; + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** Decode base64url string to bytes */ +export function base64urlDecode(str: string): Uint8Array | null { + try { + const padded = str.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(padded); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + } catch { + return null; + } +} + +/** Convert base64url string to ArrayBuffer */ +export function base64urlToBuffer(base64url: string): ArrayBuffer { + const bytes = base64urlDecode(base64url); + + invariant( + bytes, + "WebAuthn options from the server are well-formed base64url", + ); + + // Create a fresh ArrayBuffer (not SharedArrayBuffer) for WebAuthn API compatibility + return new Uint8Array(bytes).buffer; +} + +/** Convert ArrayBuffer to base64url string */ +export function bufferToBase64url(buffer: ArrayBuffer): string { + return base64urlEncode(new Uint8Array(buffer)); +} + +/** Compute SHA-256 hash of data */ +export async function sha256(data: Uint8Array): Promise { + // Create a fresh ArrayBuffer to satisfy TypeScript's BufferSource type + const buffer = new Uint8Array(data).buffer; + const hash = await crypto.subtle.digest("SHA-256", buffer); + return new Uint8Array(hash); +} + +/** Import a secret string as an HMAC-SHA256 key */ +async function importHmacKey( + secret: string, + usages: KeyUsage[], +): Promise { + try { + return await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + usages, + ); + } catch { + return null; + } +} + +/** Sign a payload string with HMAC-SHA256, return base64url signature */ +export async function hmacSign( + payload: string, + secret: string, +): Promise { + const key = await importHmacKey(secret, ["sign"]); + if (!key) return null; + + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(payload), + ); + + return base64urlEncode(new Uint8Array(signature)); +} + +/** Verify an HMAC-SHA256 signature (constant-time comparison) */ +export async function hmacVerify( + payload: string, + signature: string, + secret: string, +): Promise { + const sigBytes = base64urlDecode(signature); + if (!sigBytes) return false; + + const key = await importHmacKey(secret, ["verify"]); + if (!key) return false; + + // Create a new ArrayBuffer to satisfy TypeScript's BufferSource type + const sigBuffer = new Uint8Array(sigBytes).buffer; + return crypto.subtle.verify("HMAC", key, sigBuffer, encoder.encode(payload)); +} + +/** Encode a JSON payload to base64url */ +// TODO: Rename to jsonToBase64Url +export function encodePayload(payload: object): string { + return base64urlEncode(JSON.stringify(payload)); +} + +/** Decode a base64url string to JSON payload */ +// TODO: Rename to base64UrlToJson +export function decodePayload(encoded: string): unknown { + const bytes = base64urlDecode(encoded); + if (!bytes) return null; + + try { + const parsed: unknown = JSON.parse(decoder.decode(bytes)); + return parsed; + } catch { + return null; + } +} + +/** Unguessable base64url token from byteCount random bytes */ +export function randomBase64url(byteCount: number): string { + return base64urlEncode(crypto.getRandomValues(new Uint8Array(byteCount))); +} diff --git a/packages/spike/src/webauthn/lib.ts b/packages/spike/src/webauthn/lib.ts new file mode 100644 index 0000000..922a767 --- /dev/null +++ b/packages/spike/src/webauthn/lib.ts @@ -0,0 +1,24 @@ +/** + * Invariant assertion + * + * A _type assertion_ tells the compiler to believe a condition with no check. + * An _invariant assertion_ checks the condition at runtime and throws if it + * fails. Both narrow the type. + * + * This project bans type assertions and uses invariant assertions instead. + * + * A failed invariant is a bug, not bad input. + */ +export function invariant( + /** The condition that must hold. */ + condition: unknown, + /** Why the condition cannot fail. */ + message: string, +): asserts condition { + if (!condition) throw new Error(`Invariant violation: ${message}`); +} + +/** True for any non-null object. Narrows it so keys can be read. */ +export function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} diff --git a/packages/spike/src/webauthn/webauthn.ts b/packages/spike/src/webauthn/webauthn.ts new file mode 100644 index 0000000..4d7d7fe --- /dev/null +++ b/packages/spike/src/webauthn/webauthn.ts @@ -0,0 +1,451 @@ +/** + * WebAuthn verification utilities + * + * Implements credential verification using Web Crypto API. + * No external dependencies. + */ + +import { base64urlDecode, base64urlEncode, sha256 } from "./crypto"; +import { decodeCbor, type CborValue } from "./cbor"; +import type { + PasskeyRegistrationCredential, + PasskeyAuthenticationCredential, +} from "./contracts"; + +const encoder = new TextEncoder(); + +type ClientData = { + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type */ + type: string; // "webauthn.create" or "webauthn.get" + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-challenge */ + challenge: string; + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-origin */ + origin: string; + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-crossorigin */ + crossOrigin: boolean; +}; + +/** Decodes and validates clientDataJSON. Malformed input returns null. */ +export function parseClientData(clientDataJSON: string): ClientData | null { + const bytes = base64urlDecode(clientDataJSON); + if (bytes === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) return null; + const type = "type" in parsed ? parsed.type : null; + const challenge = "challenge" in parsed ? parsed.challenge : null; + const origin = "origin" in parsed ? parsed.origin : null; + const crossOrigin = "crossOrigin" in parsed ? parsed.crossOrigin : false; + + if ( + typeof type !== "string" || + typeof challenge !== "string" || + typeof origin !== "string" + ) { + return null; + } + + return { type, challenge, origin, crossOrigin: crossOrigin === true }; +} + +type ParsedAuthData = { + rpIdHash: Uint8Array; + flags: number; + signCount: number; + userPresent: boolean; + userVerified: boolean; + credentialId?: Uint8Array | undefined; + coseKey?: Map | undefined; +}; + +/** Compare two Uint8Arrays for equality */ +function arrayEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** Concatenate two Uint8Arrays */ +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +/** + * The origin must match one allowed origin exactly (scheme + host + port), + * and cross-origin ceremonies are rejected. + */ +function verifyOrigin(clientData: ClientData, allowedOrigins: string[]): void { + if (clientData.crossOrigin) { + throw new Error("Cross-origin ceremony rejected"); + } + if (!allowedOrigins.includes(clientData.origin)) { + throw new Error(`Origin not allowed: ${clientData.origin}`); + } +} + +/** + * Convert DER-encoded ECDSA signature to raw format + * + * WebAuthn returns signatures in DER format: + * 0x30 0x02 0x02 + * + * Web Crypto expects raw format: r || s (32 bytes each for P-256) + */ +function derToRaw(der: Uint8Array): Uint8Array { + function byteAt(i: number): number { + const byte = der[i]; + if (byte === undefined) { + throw new Error("DER signature truncated"); + } + return byte; + } + + // Parse DER sequence + if (byteAt(0) !== 0x30) { + throw new Error("Invalid DER signature: expected sequence"); + } + + let offset = 2; // skip 0x30 and length byte + + // Parse r integer + if (byteAt(offset) !== 0x02) { + throw new Error("Invalid DER signature: expected integer tag for r"); + } + const rLen = byteAt(offset + 1); + if (offset + 2 + rLen > der.length) { + throw new Error("DER signature truncated"); + } + let r = der.subarray(offset + 2, offset + 2 + rLen); + offset += 2 + rLen; + + // Parse s integer + if (byteAt(offset) !== 0x02) { + throw new Error("Invalid DER signature: expected integer tag for s"); + } + const sLen = byteAt(offset + 1); + if (offset + 2 + sLen > der.length) { + throw new Error("DER signature truncated"); + } + let s = der.subarray(offset + 2, offset + 2 + sLen); + + // DER integers may have leading zero for positive numbers + // Strip leading zeros but keep 32 bytes + if (r.length > 32) r = r.subarray(r.length - 32); + if (s.length > 32) s = s.subarray(s.length - 32); + + // Pad to 32 bytes each + const raw = new Uint8Array(64); + raw.set(r, 32 - r.length); + raw.set(s, 64 - s.length); + + return raw; +} + +/** + * Parse authenticator data + * + * Format: + * rpIdHash (32 bytes) + * flags (1 byte) + * signCount (4 bytes, big-endian) + * [attestedCredentialData] (if AT flag set) + * [extensions] (if ED flag set) + * + * Attested credential data format: + * aaguid (16 bytes) + * credentialIdLength (2 bytes, big-endian) + * credentialId (credentialIdLength bytes) + * credentialPublicKey (COSE, remaining bytes) + */ +function parseAuthData(authData: Uint8Array): ParsedAuthData { + if (authData.length < 37) { + throw new Error("Authenticator data too short"); + } + + const rpIdHash = authData.subarray(0, 32); + const view = new DataView( + authData.buffer, + authData.byteOffset, + authData.byteLength, + ); + const flags = view.getUint8(32); + const signCount = view.getUint32(33, false); + + const userPresent = !!(flags & 0x01); + const userVerified = !!(flags & 0x04); + const attestedCredentialData = !!(flags & 0x40); + + let credentialId: Uint8Array | undefined; + let coseKey: Map | undefined; + + if (attestedCredentialData) { + if (authData.length < 55) { + throw new Error("Attested credential data too short"); + } + // Skip aaguid (16 bytes), read credentialIdLength + const credIdLen = new DataView( + authData.buffer, + authData.byteOffset + 53, + 2, + ).getUint16(0, false); + if (authData.length < 55 + credIdLen) { + throw new Error("Credential id out of bounds"); + } + + credentialId = authData.subarray(55, 55 + credIdLen); + const publicKeyBytes = authData.subarray(55 + credIdLen); + const decoded = decodeCbor(publicKeyBytes); + if (!(decoded instanceof Map)) { + throw new Error("COSE key is not a map"); + } + coseKey = decoded; + } + + return { + rpIdHash, + flags, + signCount, + userPresent, + userVerified, + credentialId, + coseKey, + }; +} + +/** + * Serialize COSE key to Uint8Array for storage + * + * We store just the raw x,y coordinates (64 bytes) with a type prefix + * Format: 0x04 || x (32 bytes) || y (32 bytes) + */ +function serializeCoseKey(coseKey: Map): Uint8Array { + const kty = coseKey.get(1); + const alg = coseKey.get(3); + + if (kty !== 2 || alg !== -7) { + throw new Error("Only ES256 (P-256) keys supported"); + } + + const x = coseKey.get(-2); + const y = coseKey.get(-3); + if ( + !(x instanceof Uint8Array) || + !(y instanceof Uint8Array) || + x.length !== 32 || + y.length !== 32 + ) { + throw new Error("Invalid P-256 coordinates"); + } + + // Uncompressed point format: 0x04 || x || y + const result = new Uint8Array(65); + result[0] = 0x04; + result.set(x, 1); + result.set(y, 33); + return result; +} + +/** + * Import stored public key as CryptoKey + * + * Expects format: 0x04 || x (32 bytes) || y (32 bytes) + */ +async function importStoredKey(publicKey: Uint8Array): Promise { + if (publicKey.length !== 65 || publicKey[0] !== 0x04) { + throw new Error("Invalid stored public key format"); + } + + const x = publicKey.subarray(1, 33); + const y = publicKey.subarray(33, 65); + + return crypto.subtle.importKey( + "jwk", + { + kty: "EC", + crv: "P-256", + x: base64urlEncode(x), + y: base64urlEncode(y), + }, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); +} + +type WebAuthnPolicy = { + rpId: string; + allowedOrigins: string[]; +}; + +export type VerifyRegistrationResult = { + credentialId: string; + publicKey: Uint8Array; + counter: number; +}; + +/** + * Verify a WebAuthn registration credential + */ +export async function verifyRegistrationCredential( + credential: PasskeyRegistrationCredential, + expectedChallenge: string, + policy: WebAuthnPolicy, +): Promise { + // 1. Decode and verify clientDataJSON + const clientData = parseClientData(credential.response.clientDataJSON); + if (!clientData) { + throw new Error("Invalid clientDataJSON"); + } + + // https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type + if (clientData.type !== "webauthn.create") { + throw new Error("Invalid clientData type: expected webauthn.create"); + } + + if (clientData.challenge !== expectedChallenge) { + throw new Error("Challenge mismatch"); + } + + verifyOrigin(clientData, policy.allowedOrigins); + + // 2. Decode attestationObject (CBOR) + const attestationBytes = base64urlDecode( + credential.response.attestationObject, + ); + if (!attestationBytes) { + throw new Error("Invalid attestationObject encoding"); + } + const attestationObject = decodeCbor(attestationBytes); + if (!(attestationObject instanceof Map)) { + throw new Error("attestationObject is not a map"); + } + + const authData = attestationObject.get("authData"); + if (!(authData instanceof Uint8Array)) { + throw new Error("Missing authData in attestationObject"); + } + + // 3. Parse authData + const parsed = parseAuthData(authData); + + // 4. Verify rpIdHash + const expectedRpIdHash = await sha256(encoder.encode(policy.rpId)); + if (!arrayEqual(parsed.rpIdHash, expectedRpIdHash)) { + throw new Error("RP ID hash mismatch"); + } + + // 5. Verify flags + if (!parsed.userPresent) { + throw new Error("User presence required"); + } + + // 6. Extract credential data + if (!parsed.credentialId || !parsed.coseKey) { + throw new Error("No credential data in authData"); + } + + // For "none" attestation (which we use), we skip attestation verification + // and trust the credential. This is acceptable for most use cases. + + return { + credentialId: base64urlEncode(parsed.credentialId), + publicKey: serializeCoseKey(parsed.coseKey), + counter: parsed.signCount, + }; +} + +export type VerifyAuthenticationResult = { + counter: number; +}; + +/** + * Verify a WebAuthn authentication credential + */ +export async function verifyAuthenticationCredential( + credential: PasskeyAuthenticationCredential, + storedCredential: { publicKey: Uint8Array; counter: number }, + expectedChallenge: string, + policy: WebAuthnPolicy, +): Promise { + // 1. Decode and verify clientDataJSON + const clientDataBytes = base64urlDecode(credential.response.clientDataJSON); + const clientData = parseClientData(credential.response.clientDataJSON); + if (!clientDataBytes || !clientData) { + throw new Error("Invalid clientDataJSON"); + } + + // https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type + if (clientData.type !== "webauthn.get") { + throw new Error("Invalid clientData type: expected webauthn.get"); + } + + if (clientData.challenge !== expectedChallenge) { + throw new Error("Challenge mismatch"); + } + + verifyOrigin(clientData, policy.allowedOrigins); + + // 2. Decode authenticatorData + const authData = base64urlDecode(credential.response.authenticatorData); + if (!authData) { + throw new Error("Invalid authenticatorData encoding"); + } + const parsed = parseAuthData(authData); + + // 3. Verify rpIdHash + const expectedRpIdHash = await sha256(encoder.encode(policy.rpId)); + if (!arrayEqual(parsed.rpIdHash, expectedRpIdHash)) { + throw new Error("RP ID hash mismatch"); + } + + // 4. Verify user presence + if (!parsed.userPresent) { + throw new Error("User presence required"); + } + + // 5. Verify counter (replay protection) + // Counter of 0 means the authenticator doesn't support counters + if ( + storedCredential.counter !== 0 && + parsed.signCount !== 0 && + parsed.signCount <= storedCredential.counter + ) { + throw new Error("Signature counter replay detected"); + } + + // 6. Verify signature + const clientDataHash = await sha256(clientDataBytes); + const signedData = concat(authData, clientDataHash); + const signature = base64urlDecode(credential.response.signature); + if (!signature) { + throw new Error("Invalid signature encoding"); + } + + const publicKey = await importStoredKey(storedCredential.publicKey); + const rawSignature = derToRaw(signature); + + // Create fresh ArrayBuffers to satisfy TypeScript's BufferSource type + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + new Uint8Array(rawSignature).buffer, + new Uint8Array(signedData).buffer, + ); + + if (!valid) { + throw new Error("Invalid signature"); + } + + return { counter: parsed.signCount }; +} From 1ecfefea9878e83c1edbfba79f20b11e67deb68a Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 17:30:09 -0700 Subject: [PATCH 40/43] Make the spike passkey real --- packages/spike/spike-authenticator.ts | 161 +++++++++++++++++ packages/spike/spike-no-kernel.ts | 82 ++++----- packages/spike/src/result.ts | 4 +- packages/spike/src/strategies/passkey.ts | 220 +++++++++++++++-------- 4 files changed, 348 insertions(+), 119 deletions(-) create mode 100644 packages/spike/spike-authenticator.ts diff --git a/packages/spike/spike-authenticator.ts b/packages/spike/spike-authenticator.ts new file mode 100644 index 0000000..0def0af --- /dev/null +++ b/packages/spike/spike-authenticator.ts @@ -0,0 +1,161 @@ +import { base64urlEncode, sha256 } from "./src/webauthn/crypto"; + +/** + * A software authenticator for the playground. It stands in for the browser + * and the device, and answers ceremony options the way they would. The first + * passkey it made is the one it signs in with. + */ +export function makeAuthenticator(origin: string) { + const passkeys: { + credentialId: Uint8Array; + privateKey: CryptoKey; + uses: number; + }[] = []; + + return { + /** What navigator.credentials.create returns, as JSON */ + create: async (options: PublicKeyCredentialCreationOptionsJSON) => { + const keys = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const jwk = await crypto.subtle.exportKey("jwk", keys.publicKey); + const credentialId = crypto.getRandomValues(new Uint8Array(16)); + + passkeys.push({ credentialId, privateKey: keys.privateKey, uses: 0 }); + + const authenticatorData = [ + ...(await authenticatorDataHead(options.rp.id ?? "", 0x45, 0)), + ...new Uint8Array(16), + credentialId.length >> 8, + credentialId.length & 0xff, + ...credentialId, + ...map([ + [integer(1), integer(2)], + [integer(3), integer(-7)], + [integer(-1), integer(1)], + [integer(-2), bytes(decode(jwk.x ?? ""))], + [integer(-3), bytes(decode(jwk.y ?? ""))], + ]), + ]; + + return { + response: { + clientDataJSON: clientData("webauthn.create", options.challenge), + attestationObject: base64urlEncode( + new Uint8Array( + map([ + [text("fmt"), text("none")], + [text("attStmt"), map([])], + [text("authData"), bytes(new Uint8Array(authenticatorData))], + ]), + ), + ), + }, + }; + }, + + /** What navigator.credentials.get returns, as JSON */ + get: async (options: PublicKeyCredentialRequestOptionsJSON) => { + const [passkey] = passkeys; + + if (passkey === undefined) throw new Error("no passkey on this device"); + + passkey.uses += 1; + + const clientDataJSON = clientData("webauthn.get", options.challenge); + const authenticatorData = new Uint8Array( + await authenticatorDataHead(options.rpId ?? "", 0x05, passkey.uses), + ); + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + passkey.privateKey, + new Uint8Array([ + ...authenticatorData, + ...(await sha256(decode(clientDataJSON))), + ]), + ); + + return { + id: base64urlEncode(passkey.credentialId), + response: { + clientDataJSON, + authenticatorData: base64urlEncode(authenticatorData), + signature: base64urlEncode(der(new Uint8Array(signature))), + }, + }; + }, + }; + + function clientData(type: string, challenge: string) { + return base64urlEncode( + JSON.stringify({ type, challenge, origin, crossOrigin: false }), + ); + } +} + +/** The hash of the relying party id, the flags, and the counter */ +async function authenticatorDataHead( + rpId: string, + flags: number, + counter: number, +) { + return [ + ...(await sha256(new TextEncoder().encode(rpId))), + flags, + counter >>> 24, + (counter >> 16) & 0xff, + (counter >> 8) & 0xff, + counter & 0xff, + ]; +} + +function decode(base64url: string) { + const binary = atob(base64url.replaceAll("-", "+").replaceAll("_", "/")); + + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +/** Web Crypto signs as r and s side by side, WebAuthn sends them as DER */ +function der(signature: Uint8Array) { + const part = (value: Uint8Array) => { + const trimmed = [...value]; + + while (trimmed.length > 1 && trimmed[0] === 0) trimmed.shift(); + + const positive = (trimmed[0] ?? 0) & 0x80 ? [0, ...trimmed] : trimmed; + + return [0x02, positive.length, ...positive]; + }; + + const r = part(signature.subarray(0, 32)); + const s = part(signature.subarray(32)); + + return new Uint8Array([0x30, r.length + s.length, ...r, ...s]); +} + +// The few CBOR encoders the two responses need + +function head(major: number, length: number) { + if (length < 24) return [(major << 5) | length]; + if (length < 256) return [(major << 5) | 24, length]; + + return [(major << 5) | 25, length >> 8, length & 0xff]; +} + +function integer(value: number) { + return value >= 0 ? head(0, value) : head(1, -1 - value); +} + +function bytes(value: Uint8Array) { + return [...head(2, value.length), ...value]; +} + +function text(value: string) { + return [...head(3, value.length), ...new TextEncoder().encode(value)]; +} + +function map(entries: [number[], number[]][]) { + return [...head(5, entries.length), ...entries.flat(2)]; +} diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts index 124c763..8c8eaa6 100644 --- a/packages/spike/spike-no-kernel.ts +++ b/packages/spike/spike-no-kernel.ts @@ -5,6 +5,7 @@ import { makeSignedSessionManager, } from "./src/index"; import { makeMemoryTable } from "./src/demo/index"; +import { makeAuthenticator } from "./spike-authenticator"; /** * App @@ -22,19 +23,20 @@ const otpsTable = makeMemoryTable<{ expiresAt: number; attemptsLeft: number; }>("ticket"); -const challengesTable = makeMemoryTable< - { challenge: string } & ( - { purpose: "register"; handle: string } | { purpose: "authenticate" } - ) ->("challenge"); +const challengesTable = makeMemoryTable<{ + challenge: string; + handle: string | null; + expiresAt: number; +}>("challenge"); const credentialsTable = makeMemoryTable<{ credentialId: string; - publicKey: string; handle: string; + publicKey: string; + counter: number; }>("credentialId"); -// The fake authenticator in the browser. Credential id to its key and handle. -const authenticator = new Map(); +// Stands in for the browser and the device +const authenticator = makeAuthenticator("http://localhost:3000"); // Captures what would have been delivered, for the demo const delivered = new Map(); @@ -79,6 +81,8 @@ const smsOtp = makeOTP({ const passkey = makePasskey({ rpId: "localhost", rpName: "Spike", + origins: ["http://localhost:3000"], + ttl: 5 * 60 * 1000, storeChallenge: async (challenge, row) => { await challengesTable.insert({ challenge, ...row }); }, @@ -87,6 +91,9 @@ const passkey = makePasskey({ await credentialsTable.insert({ credentialId, ...row }); }, getCredential: (credentialId) => credentialsTable.get(credentialId), + setCounter: async (credentialId, counter) => { + await credentialsTable.update(credentialId, { counter }); + }, }); // @@ -131,15 +138,10 @@ const signUp = await passkey.beginRegistration({ name: "ripley@example.com", }); console.log("OPTIONS", signUp); -const newCredentialId = crypto.randomUUID(); -const newKey = crypto.randomUUID(); -const registered = await passkey.finishRegistration({ - challenge: signUp.challenge, - credentialId: newCredentialId, - publicKey: newKey, -}); +const registered = await passkey.finishRegistration( + await authenticator.create(signUp), +); if (!registered.success) throw new Error(registered.error); -authenticator.set(newCredentialId, { key: newKey, handle: signUp.user.id }); const signUpSessionId = await opaque.make(registered.data, { userId: registered.data.proven.userHandle, @@ -148,16 +150,9 @@ console.log("SESSION", await opaque.get(signUpSessionId)); // Passkey sign-in. 1. begin, 2. browser signs, 3. finish, 4. session const signIn = await passkey.beginAuthentication(); -const [credentialId, stored] = authenticator.entries().next().value ?? [ - "", - { key: "", handle: "" }, -]; -const authenticated = await passkey.finishAuthentication({ - challenge: signIn.challenge, - credentialId, - signature: stored.key, - userHandle: stored.handle, -}); +const authenticated = await passkey.finishAuthentication( + await authenticator.get(signIn), +); if (!authenticated.success) throw new Error(authenticated.error); const signInSessionId = await opaque.make(authenticated.data, { @@ -174,15 +169,8 @@ const add = await passkey.beginRegistration({ handle: current.userId, name: "ripley@example.com", }); -const secondCredentialId = crypto.randomUUID(); -const secondKey = crypto.randomUUID(); -const added = await passkey.finishRegistration({ - challenge: add.challenge, - credentialId: secondCredentialId, - publicKey: secondKey, -}); +const added = await passkey.finishRegistration(await authenticator.create(add)); if (!added.success) throw new Error(added.error); -authenticator.set(secondCredentialId, { key: secondKey, handle: add.user.id }); console.log("ADDED", added.data.proven); // Signed session manager. Same three steps, no table. The token carries the @@ -216,14 +204,28 @@ console.log( "USED", await emailOtp.verify({ ticket: emailTicket, otp: "nope" }), ); + +// A device with a passkey the app never stored +const stranger = makeAuthenticator("http://localhost:3000"); +await stranger.create( + await passkey.beginRegistration({ handle: "nobody", name: "nobody" }), +); console.log( "STRANGER", - await passkey.finishAuthentication({ - challenge: (await passkey.beginAuthentication()).challenge, - credentialId: "not-a-credential", - signature: "", - userHandle: "", - }), + await passkey.finishAuthentication( + await stranger.get(await passkey.beginAuthentication()), + ), +); + +// The right passkey, presented from another site +const elsewhere = makeAuthenticator("http://evil.example"); +console.log( + "ELSEWHERE", + await passkey.finishRegistration( + await elsewhere.create( + await passkey.beginRegistration({ handle: ripley, name: "ripley" }), + ), + ), ); // Reuse the proof. Rejected at runtime diff --git a/packages/spike/src/result.ts b/packages/spike/src/result.ts index 2a97f3f..c12959b 100644 --- a/packages/spike/src/result.ts +++ b/packages/spike/src/result.ts @@ -7,9 +7,9 @@ type Reason = | "expired_otp" | "wrong_otp" | "unknown_challenge" + | "expired_challenge" | "unknown_credential" - | "wrong_signature" - | "wrong_handle"; + | "invalid_credential"; /** What a strategy returns. Narrow on success, then read data or error. */ export type Result = Success | Failure; diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts index e357263..d01d81b 100644 --- a/packages/spike/src/strategies/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -1,41 +1,82 @@ import { fail, succeed, type Result } from "../result"; import { issueProof, type Proof } from "../proof"; +import type { + PasskeyAuthenticationCredential, + PasskeyRegistrationCredential, +} from "../webauthn/contracts"; +import { + base64urlDecode, + base64urlEncode, + randomBase64url, +} from "../webauthn/crypto"; +import { invariant } from "../webauthn/lib"; +import { + parseClientData, + verifyAuthenticationCredential, + verifyRegistrationCredential, +} from "../webauthn/webauthn"; + +/** What the app stores for one challenge */ +type ChallengeRow = { + /** Who a registration is for. Null when the challenge is for authentication. */ + handle: string | null; + /** When the challenge stops working, in ms since the epoch */ + expiresAt: number; +}; + +/** What the app stores for one passkey */ +type CredentialRow = { + /** Who the passkey belongs to */ + handle: string; + publicKey: string; + /** How many times the authenticator says the passkey was used */ + counter: number; +}; /** - * Fake. No WebAuthn, the "signature" is the public key sent back as is. - * Only the shape of the two ceremonies is real. The library stores the - * credential with the handle the app gave it and hands the handle back on - * authentication. What the handle means is the app's business. + * The library stores the credential with the handle the app gave it and hands + * the handle back on authentication. What the handle means is the app's + * business. */ - -/** A registration challenge carries the handle until the ceremony finishes */ -type Challenge = - { purpose: "register"; handle: string } | { purpose: "authenticate" }; - export function makePasskey(args: { /** The relying party id, the domain passkeys are bound to */ rpId: string; /** The relying party name, shown by the authenticator */ rpName: string; + /** Every origin a ceremony may run from, as scheme, host, and port */ + origins: string[]; + /** Lifetime of a challenge in ms */ + ttl: number; /** Stores a challenge row under the challenge */ - storeChallenge: (challenge: string, row: Challenge) => Promise; + storeChallenge: (challenge: string, row: ChallengeRow) => Promise; /** Removes the row for a challenge and returns it, atomically. Null when there is none. */ - takeChallenge: (challenge: string) => Promise; + takeChallenge: (challenge: string) => Promise; /** Stores a credential row under the id the authenticator chose */ - storeCredential: ( - credentialId: string, - row: { publicKey: string; handle: string }, - ) => Promise; + storeCredential: (credentialId: string, row: CredentialRow) => Promise; /** Reads the credential row for an id, null when there is none */ - getCredential: ( - credentialId: string, - ) => Promise<{ publicKey: string; handle: string } | null>; + getCredential: (credentialId: string) => Promise; + /** Writes the counter of a credential after the passkey was used */ + setCounter: (credentialId: string, counter: number) => Promise; }) { + const policy = { rpId: args.rpId, allowedOrigins: args.origins }; + + const makeChallenge = async (handle: string | null) => { + const challenge = randomBase64url(32); + + await args.storeChallenge(challenge, { + handle, + expiresAt: Date.now() + args.ttl, + }); + + return challenge; + }; + return { /** - * handle is the app's stable id for the person, the authenticator keeps it - * with the credential and returns it on authentication. name is what the - * authenticator shows, an email or a username. + * Returns the options the browser creates a passkey from. handle is the + * app's stable id for the person, the authenticator keeps it with the + * credential. name is what the authenticator shows, an email or a + * username. */ beginRegistration: async ({ handle, @@ -43,84 +84,109 @@ export function makePasskey(args: { }: { handle: string; name: string; - }) => { - const challenge = crypto.randomUUID(); - - await args.storeChallenge(challenge, { purpose: "register", handle }); - - return { - challenge, - rp: { id: args.rpId, name: args.rpName }, - user: { id: handle, name }, - }; - }, - - finishRegistration: async ({ - challenge, - credentialId, - publicKey, - }: { - challenge: string; - credentialId: string; - publicKey: string; - }): Promise< + }): Promise => ({ + challenge: await makeChallenge(handle), + rp: { id: args.rpId, name: args.rpName }, + user: { id: base64urlEncode(handle), name, displayName: name }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + attestation: "none", + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }), + + /** Takes what the browser made from the options, and stores the passkey */ + finishRegistration: async ( + credential: PasskeyRegistrationCredential, + ): Promise< Result< Proof<{ credentialId: string; userHandle: string }>, - "unknown_challenge" + "unknown_challenge" | "expired_challenge" | "invalid_credential" > > => { - const row = await args.takeChallenge(challenge); + const clientData = parseClientData(credential.response.clientDataJSON); - if (row === null) return fail("unknown_challenge"); - if (row.purpose !== "register") return fail("unknown_challenge"); + if (clientData === null) return fail("invalid_credential"); - await args.storeCredential(credentialId, { - publicKey, - handle: row.handle, - }); + const row = await args.takeChallenge(clientData.challenge); - return succeed(issueProof({ credentialId, userHandle: row.handle })); - }, + if (row === null || row.handle === null) return fail("unknown_challenge"); + if (row.expiresAt <= Date.now()) return fail("expired_challenge"); - beginAuthentication: async () => { - const challenge = crypto.randomUUID(); + const verified = await verifyRegistrationCredential( + credential, + clientData.challenge, + policy, + ).catch(() => null); - await args.storeChallenge(challenge, { purpose: "authenticate" }); + if (verified === null) return fail("invalid_credential"); - return { challenge, rpId: args.rpId }; + await args.storeCredential(verified.credentialId, { + handle: row.handle, + publicKey: base64urlEncode(verified.publicKey), + counter: verified.counter, + }); + + return succeed( + issueProof({ + credentialId: verified.credentialId, + userHandle: row.handle, + }), + ); }, - finishAuthentication: async ({ - challenge, - credentialId, - signature, - userHandle, - }: { - challenge: string; - credentialId: string; - signature: string; - userHandle: string; - }): Promise< + /** Returns the options the browser signs in with */ + beginAuthentication: + async (): Promise => ({ + challenge: await makeChallenge(null), + rpId: args.rpId, + userVerification: "preferred", + }), + + /** Takes what the browser made from the options */ + finishAuthentication: async ( + credential: PasskeyAuthenticationCredential, + ): Promise< Result< Proof<{ credentialId: string; userHandle: string }>, | "unknown_challenge" + | "expired_challenge" | "unknown_credential" - | "wrong_signature" - | "wrong_handle" + | "invalid_credential" > > => { - const row = await args.takeChallenge(challenge); + const clientData = parseClientData(credential.response.clientDataJSON); + + if (clientData === null) return fail("invalid_credential"); + + const row = await args.takeChallenge(clientData.challenge); + + if (row === null || row.handle !== null) return fail("unknown_challenge"); + if (row.expiresAt <= Date.now()) return fail("expired_challenge"); + + const stored = await args.getCredential(credential.id); + + if (stored === null) return fail("unknown_credential"); + + const publicKey = base64urlDecode(stored.publicKey); + + invariant(publicKey, "the library stored the public key as base64url"); - if (row === null) return fail("unknown_challenge"); - if (row.purpose !== "authenticate") return fail("unknown_challenge"); + const verified = await verifyAuthenticationCredential( + credential, + { publicKey, counter: stored.counter }, + clientData.challenge, + policy, + ).catch(() => null); - const credential = await args.getCredential(credentialId); + if (verified === null) return fail("invalid_credential"); - if (credential === null) return fail("unknown_credential"); - if (credential.publicKey !== signature) return fail("wrong_signature"); - if (credential.handle !== userHandle) return fail("wrong_handle"); + await args.setCounter(credential.id, verified.counter); - return succeed(issueProof({ credentialId, userHandle })); + return succeed( + issueProof({ credentialId: credential.id, userHandle: stored.handle }), + ); }, }; } From 6a4d184ab2e45b1fe0adadb1bf5ab0730acc6eb7 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 17:30:09 -0700 Subject: [PATCH 41/43] Rewire passkey example to spike --- bun.lock | 3 +- .../passkey-memory/package.json | 5 +- .../passkey-memory/src/auth-rpc.ts | 146 +++++++++++------- .../passkey-memory/src/auth.ts | 85 +++++++--- .../passkey-memory/src/db.ts | 90 ++++------- .../passkey-memory/src/session-cookie.ts | 5 +- 6 files changed, 185 insertions(+), 149 deletions(-) diff --git a/bun.lock b/bun.lock index 26b31b4..86f430a 100644 --- a/bun.lock +++ b/bun.lock @@ -181,11 +181,10 @@ "name": "@repo/example-tanstack-passkey", "dependencies": { "@repo/shared-react": "workspace:*", - "@repo/shared-webauthn": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.39", "@tanstack/react-start": "^1.168.58", - "authax": "workspace:*", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", diff --git a/examples/tanstack-start-react/passkey-memory/package.json b/examples/tanstack-start-react/passkey-memory/package.json index 9ffa4db..7407dc4 100644 --- a/examples/tanstack-start-react/passkey-memory/package.json +++ b/examples/tanstack-start-react/passkey-memory/package.json @@ -10,16 +10,15 @@ "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", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", - "zod": "^4.6.5", - "@repo/shared-webauthn": "workspace:*" + "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^26.6.3", diff --git a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts index b87b3bc..057443b 100644 --- a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts @@ -1,13 +1,34 @@ import { createServerFn } from "@tanstack/react-start"; -import { - passkeyAuthenticationCredentialSchema, - passkeyRegistrationCredentialSchema, -} from "@repo/shared-webauthn"; import { z } from "zod"; import { db } from "./db"; -import { auth } from "./auth"; +import { passkey, 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); +} + +/** What the browser sends after it created a passkey */ +const registrationCredentialSchema = z.object({ + response: z.object({ + clientDataJSON: z.string(), + attestationObject: z.string(), + }), +}); + +/** What the browser sends after it signed in with a passkey */ +const authenticationCredentialSchema = z.object({ + id: z.string(), + response: z.object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string(), + }), +}); + /** * Server function: Start passkey registration * @@ -15,13 +36,13 @@ import { sessionCookie } from "./session-cookie"; * application user is provisioned only after the ceremony verifies. */ export const startRegistration = createServerFn({ method: "POST" }).handler( - async () => { - const result = await auth.strategies.passkeys.createRegistrationOptions(); - - if (!result.success) return { success: false as const }; - - return { success: true as const, options: result.data }; - }, + async () => ({ + success: true as const, + options: await passkey.beginRegistration({ + handle: crypto.randomUUID(), + name: "New user", + }), + }), ); /** @@ -31,15 +52,19 @@ export const startRegistration = createServerFn({ method: "POST" }).handler( * establishes a session. */ export const verifyRegistration = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyRegistration({ - credential: data.credential, - }); + const result = await passkey.finishRegistration(data.credential); if (!result.success) return { success: false as const }; - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + const userId = result.data.proven.userHandle; + const { isNew } = await db.users.findOrInsert({ userId }, { userId }); + + // A ceremony that was started to add a passkey signs nobody up + if (!isNew) return { success: false as const }; + + sessionCookie.set(await sessionManager.make(result.data, { userId })); return { success: true as const }; }); @@ -52,14 +77,16 @@ export const verifyRegistration = createServerFn({ method: "POST" }) */ export const startAddPasskey = createServerFn({ method: "POST" }).handler( async () => { - const result = - await auth.strategies.passkeys.createAdditionalRegistrationOptions( - sessionCookie.get(), - ); - - if (!result.success) return { success: false as const }; + const identity = await getIdentity(); + if (!identity) return { success: false as const }; - return { success: true as const, options: result.data }; + return { + success: true as const, + options: await passkey.beginRegistration({ + handle: identity.userId, + name: identity.userId, + }), + }; }, ); @@ -70,15 +97,23 @@ export const startAddPasskey = createServerFn({ method: "POST" }).handler( * for the current user. No session is established. */ export const verifyAddPasskey = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAdditionalRegistration( - sessionCookie.get(), - { credential: data.credential }, - ); + const identity = await getIdentity(); + if (!identity) return { success: false as const }; + + const result = await passkey.finishRegistration(data.credential); if (!result.success) return { success: false as const }; + // The ceremony has to be one this user started. The passkey is already + // stored when the answer comes back, so it is removed again. + if (result.data.proven.userHandle !== identity.userId) { + await db.credentials.delete(result.data.proven.credentialId); + + return { success: false as const }; + } + return { success: true as const }; }); @@ -89,17 +124,10 @@ export const verifyAddPasskey = createServerFn({ method: "POST" }) */ export const startAuthentication = createServerFn({ method: "POST", -}).handler(async () => { - const result = await auth.strategies.passkeys.createAuthenticationOptions(); - return { success: true as const, options: result.data }; -}); - -/** - * Verify passkey authentication schema - */ -const verifyAuthenticationSchema = z.object({ - credential: passkeyAuthenticationCredentialSchema, -}); +}).handler(async () => ({ + success: true as const, + options: await passkey.beginAuthentication(), +})); /** * Verify passkey authentication @@ -108,15 +136,17 @@ const verifyAuthenticationSchema = z.object({ * establishes a session. */ export const verifyAuthentication = createServerFn({ method: "POST" }) - .validator(verifyAuthenticationSchema) + .validator(z.object({ credential: authenticationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAuthentication({ - credential: data.credential, - }); + const result = await passkey.finishAuthentication(data.credential); if (!result.success) return { success: false as const }; - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + sessionCookie.set( + await sessionManager.make(result.data, { + userId: result.data.proven.userHandle, + }), + ); return { success: true as const }; }); @@ -127,12 +157,12 @@ export const verifyAuthentication = createServerFn({ method: "POST" }) * Returns stored credential metadata for the authenticated user. */ export const listPasskeys = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { passkeys: [] }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); return { - passkeys: passkeys.map((p) => ({ id: p.credentialId })), + passkeys: passkeys.map((p) => ({ id: p.id })), }; }); @@ -144,16 +174,16 @@ export const listPasskeys = createServerFn().handler(async () => { export const removePasskey = createServerFn({ method: "POST" }) .validator(z.object({ credentialId: z.string() })) .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { success: false as const }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); if (passkeys.length <= 1) return { success: false as const }; - const owns = passkeys.some((p) => p.credentialId === data.credentialId); + const owns = passkeys.some((p) => p.id === data.credentialId); if (!owns) return { success: false as const }; - db.credentials.delete(data.credentialId); + await db.credentials.delete(data.credentialId); return { success: true as const }; }); @@ -163,7 +193,9 @@ export const removePasskey = 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(); }); @@ -174,8 +206,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(); }, ); @@ -186,7 +218,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; }); diff --git a/examples/tanstack-start-react/passkey-memory/src/auth.ts b/examples/tanstack-start-react/passkey-memory/src/auth.ts index 78bd7d2..e40c42b 100644 --- a/examples/tanstack-start-react/passkey-memory/src/auth.ts +++ b/examples/tanstack-start-react/passkey-memory/src/auth.ts @@ -1,30 +1,65 @@ -import { - makeAuth, - makeOpaqueSession, - makePasskeyEngine, - makePasskeyStrategy, -} from "authax"; +import { makeOpaqueSessionManager, makePasskey } 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); -const passkey = makePasskeyEngine({ - storage: db.credentials, - challenge: { storage: db.challenges, ttl: 5 * 60 * 1000 }, - webAuthn: { - rpId: "localhost", - rpName: "Auth Passkey Demo", - allowedOrigins: ["http://localhost:3107"], - }, - displayName: async (context) => - context.intent === "add" ? context.userId : "New user", - signUp: async () => db.users.create().userId, - debug: true, + return row + ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } + : null; + }, + delete: async (token) => { + await db.sessions.delete(token); + }, + ttl: sessionTtl, }); -export const auth = makeAuth(session, (kernel) => ({ - passkeys: makePasskeyStrategy(kernel, passkey), -})); +export const passkey = makePasskey({ + rpId: "localhost", + rpName: "Auth Passkey Demo", + origins: ["http://localhost:3107"], + ttl: 5 * 60 * 1000, + storeChallenge: async (challenge, row) => { + await db.challenges.insert({ + id: challenge, + userId: row.handle, + expiresAt: new Date(row.expiresAt), + }); + }, + takeChallenge: async (challenge) => { + const row = await db.challenges.delete(challenge); + + return row + ? { handle: row.userId, expiresAt: row.expiresAt.getTime() } + : null; + }, + storeCredential: async (credentialId, row) => { + await db.credentials.insert({ + id: credentialId, + userId: row.handle, + publicKey: row.publicKey, + counter: row.counter, + }); + }, + getCredential: async (credentialId) => { + const row = await db.credentials.get(credentialId); + + return row + ? { handle: row.userId, publicKey: row.publicKey, counter: row.counter } + : null; + }, + setCounter: async (credentialId, counter) => { + await db.credentials.update(credentialId, { counter }); + }, +}); diff --git a/examples/tanstack-start-react/passkey-memory/src/db.ts b/examples/tanstack-start-react/passkey-memory/src/db.ts index 5043359..6aff738 100644 --- a/examples/tanstack-start-react/passkey-memory/src/db.ts +++ b/examples/tanstack-start-react/passkey-memory/src/db.ts @@ -4,74 +4,44 @@ * Simple in-memory stores for demonstration purposes. In a real app these * would be replaced with database queries. */ -import type { ChallengeRecord, CredentialRecord, SessionRecord } from "authax"; - -const users = new Map(); -let userIdCounter = 0; - -const sessions = new Map(); -const credentials = new Map(); -const challenges = new Map(); +import { makeMemoryTable } from "@repo/spike/demo"; + +const users = makeMemoryTable<{ userId: string }>("userId"); + +const sessions = makeMemoryTable<{ + id: string; + userId: string; + expiresAt: Date; +}>("id"); + +const credentials = makeMemoryTable<{ + id: string; + userId: string; + publicKey: string; + counter: number; +}>("id"); + +const challenges = makeMemoryTable<{ + id: string; + /** Who a registration is for. Null when the challenge is for signing in. */ + userId: string | null; + expiresAt: Date; +}>("id"); export const db = { - users: { - create: () => { - const userId = `user_${++userIdCounter}`; - users.set(userId, { userId }); - return { userId }; - }, - get: (userId: string) => users.get(userId), - }, + users, sessions: { - store: async (record: SessionRecord) => { - sessions.set(record.sessionId, record); - }, + ...sessions, - get: async (sessionId: string) => sessions.get(sessionId) ?? null, - - delete: async (sessionId: string) => { - sessions.delete(sessionId); - }, - - deleteAllForUser: (userId: string) => { - for (const [sessionId, record] of sessions) { - if (record.userId === userId) { - sessions.delete(sessionId); - } + deleteAllForUser: async (userId: string) => { + for (const session of await sessions.where({ userId })) { + await sessions.delete(session.id); } }, }, - credentials: { - store: async (record: CredentialRecord) => { - credentials.set(record.credentialId, record); - }, - - get: async (credentialId: string) => credentials.get(credentialId) ?? null, - - list: async (userId: string) => - Array.from(credentials.values()).filter((c) => c.userId === userId), - - setCounter: async (credentialId: string, counter: number) => { - const record = credentials.get(credentialId); - if (record) credentials.set(credentialId, { ...record, counter }); - }, - - delete: (credentialId: string) => { - credentials.delete(credentialId); - }, - }, - - challenges: { - store: async (record: ChallengeRecord) => { - challenges.set(record.challenge, record); - }, + credentials, - take: async (challenge: string) => { - const record = challenges.get(challenge) ?? null; - challenges.delete(challenge); - return record; - }, - }, + challenges, }; diff --git a/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts b/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts index 60eb30c..b09ec3f 100644 --- a/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts +++ b/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts @@ -1,4 +1,5 @@ import { getCookie, setCookie } from "@tanstack/react-start/server"; +import { sessionTtl } from "./auth"; const name = "session"; @@ -12,7 +13,7 @@ const options = { /** Moves the session token between the request and the auth API */ export const sessionCookie = { get: () => getCookie(name) ?? null, - set: (token: string, expiresAt: Date) => - setCookie(name, token, { ...options, expires: expiresAt }), + set: (token: string) => + setCookie(name, token, { ...options, maxAge: sessionTtl / 1000 }), clear: () => setCookie(name, "", { ...options, maxAge: 0 }), }; From 8e226df68b995f8a3250c2713d5cc3afd53c3715 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 17:36:14 -0700 Subject: [PATCH 42/43] Move button cursor to shared styles --- examples/bun-react/otp-memory-cookie/src/index.css | 8 -------- examples/bun-react/otp-memory-header/src/index.css | 8 -------- examples/convex-react/otp/src/index.css | 8 -------- examples/nextjs/otp-memory/app/globals.css | 8 -------- examples/shared/react/src/styles.css | 7 +++++++ examples/tanstack-start-react/otp-memory/src/styles.css | 8 -------- .../otp-passkey-memory/src/styles.css | 8 -------- .../otp-passkey-strict-memory/src/styles.css | 8 -------- .../tanstack-start-react/passkey-memory/src/styles.css | 8 -------- .../passkey-otp-memory/src/styles.css | 8 -------- 10 files changed, 7 insertions(+), 72 deletions(-) diff --git a/examples/bun-react/otp-memory-cookie/src/index.css b/examples/bun-react/otp-memory-cookie/src/index.css index 017b778..3306d99 100644 --- a/examples/bun-react/otp-memory-cookie/src/index.css +++ b/examples/bun-react/otp-memory-cookie/src/index.css @@ -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; - } -} diff --git a/examples/bun-react/otp-memory-header/src/index.css b/examples/bun-react/otp-memory-header/src/index.css index 017b778..3306d99 100644 --- a/examples/bun-react/otp-memory-header/src/index.css +++ b/examples/bun-react/otp-memory-header/src/index.css @@ -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; - } -} diff --git a/examples/convex-react/otp/src/index.css b/examples/convex-react/otp/src/index.css index 017b778..3306d99 100644 --- a/examples/convex-react/otp/src/index.css +++ b/examples/convex-react/otp/src/index.css @@ -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; - } -} diff --git a/examples/nextjs/otp-memory/app/globals.css b/examples/nextjs/otp-memory/app/globals.css index 017b778..3306d99 100644 --- a/examples/nextjs/otp-memory/app/globals.css +++ b/examples/nextjs/otp-memory/app/globals.css @@ -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; - } -} diff --git a/examples/shared/react/src/styles.css b/examples/shared/react/src/styles.css index 32b0d79..a084e3a 100644 --- a/examples/shared/react/src/styles.css +++ b/examples/shared/react/src/styles.css @@ -1 +1,8 @@ @source "."; + +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} diff --git a/examples/tanstack-start-react/otp-memory/src/styles.css b/examples/tanstack-start-react/otp-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-memory/src/styles.css @@ -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; - } -} diff --git a/examples/tanstack-start-react/otp-passkey-memory/src/styles.css b/examples/tanstack-start-react/otp-passkey-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-passkey-memory/src/styles.css @@ -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; - } -} diff --git a/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css b/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css @@ -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; - } -} diff --git a/examples/tanstack-start-react/passkey-memory/src/styles.css b/examples/tanstack-start-react/passkey-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/passkey-memory/src/styles.css +++ b/examples/tanstack-start-react/passkey-memory/src/styles.css @@ -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; - } -} diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/styles.css b/examples/tanstack-start-react/passkey-otp-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/styles.css +++ b/examples/tanstack-start-react/passkey-otp-memory/src/styles.css @@ -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; - } -} From 3cfaacd506317b1d9a720e1b0f1d76564cbfb1ee Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 27 Sep 2026 17:49:37 -0700 Subject: [PATCH 43/43] Log why a passkey failed --- .../passkey-memory/src/auth-rpc.ts | 18 +++++++++++++++--- packages/spike/src/strategies/passkey.ts | 12 ++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts index 057443b..a7ba63a 100644 --- a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts @@ -56,7 +56,11 @@ export const verifyRegistration = createServerFn({ method: "POST" }) .handler(async ({ data }) => { const result = await passkey.finishRegistration(data.credential); - if (!result.success) return { success: false as const }; + if (!result.success) { + console.log("[passkey] refused:", result.error); + + return { success: false as const }; + } const userId = result.data.proven.userHandle; const { isNew } = await db.users.findOrInsert({ userId }, { userId }); @@ -104,7 +108,11 @@ export const verifyAddPasskey = createServerFn({ method: "POST" }) const result = await passkey.finishRegistration(data.credential); - if (!result.success) return { success: false as const }; + if (!result.success) { + console.log("[passkey] refused:", result.error); + + return { success: false as const }; + } // The ceremony has to be one this user started. The passkey is already // stored when the answer comes back, so it is removed again. @@ -140,7 +148,11 @@ export const verifyAuthentication = createServerFn({ method: "POST" }) .handler(async ({ data }) => { const result = await passkey.finishAuthentication(data.credential); - if (!result.success) return { success: false as const }; + if (!result.success) { + console.log("[passkey] refused:", result.error); + + return { success: false as const }; + } sessionCookie.set( await sessionManager.make(result.data, { diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts index d01d81b..3bf0eac 100644 --- a/packages/spike/src/strategies/passkey.ts +++ b/packages/spike/src/strategies/passkey.ts @@ -60,6 +60,14 @@ export function makePasskey(args: { }) { const policy = { rpId: args.rpId, allowedOrigins: args.origins }; + // Spike only. Says in the server log why the verification refused a + // passkey, until the reasons are designed. + const refused = (error: unknown) => { + console.error("[passkey] refused:", error); + + return null; + }; + const makeChallenge = async (handle: string | null) => { const challenge = randomBase64url(32); @@ -118,7 +126,7 @@ export function makePasskey(args: { credential, clientData.challenge, policy, - ).catch(() => null); + ).catch(refused); if (verified === null) return fail("invalid_credential"); @@ -178,7 +186,7 @@ export function makePasskey(args: { { publicKey, counter: stored.counter }, clientData.challenge, policy, - ).catch(() => null); + ).catch(refused); if (verified === null) return fail("invalid_credential");