feat: very bad account number + passkey login

This commit is contained in:
2026-04-03 22:16:26 +02:00
parent b54ebddbb8
commit a058ccd109
15 changed files with 822 additions and 53 deletions

View File

@@ -4,11 +4,9 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "chillhop", "name": "chillhop",
"dependencies": {
"@better-auth/passkey": "^1.5.6",
},
"devDependencies": { "devDependencies": {
"@better-auth/cli": "~1.4.21", "@better-auth/cli": "~1.4.21",
"@better-auth/passkey": "~1.4.21",
"@cloudflare/workers-types": "^4.20250517.0", "@cloudflare/workers-types": "^4.20250517.0",
"@lucide/svelte": "^0.492.0", "@lucide/svelte": "^0.492.0",
"@sveltejs/adapter-cloudflare": "^7.2.6", "@sveltejs/adapter-cloudflare": "^7.2.6",
@@ -110,7 +108,7 @@
"@better-auth/core": ["@better-auth/core@1.4.21", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-R4s7pwShkqB21fZ599QASbXxqFcoxanLyz7DHSX6SJPNYV748wBLsm3xM9VrjfvWMpS+cQUErOCt9yWT1hMn6w=="], "@better-auth/core": ["@better-auth/core@1.4.21", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-R4s7pwShkqB21fZ599QASbXxqFcoxanLyz7DHSX6SJPNYV748wBLsm3xM9VrjfvWMpS+cQUErOCt9yWT1hMn6w=="],
"@better-auth/passkey": ["@better-auth/passkey@1.5.6", "", { "dependencies": { "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "1.5.6", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "better-auth": "1.5.6", "better-call": "1.3.2", "nanostores": "^1.0.1" } }, "sha512-2DQkPK5Rw7g6Zixa3MSoH31s4Au96O94+QvJl3F0LK3P6KDjEGlRh1CgzQmzafwBJjmsRx9jSwckGP6jiEEDtw=="], "@better-auth/passkey": ["@better-auth/passkey@1.4.22", "", { "dependencies": { "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/core": "1.4.22", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-auth": "1.4.22", "better-call": "1.1.8", "nanostores": "^1.0.1" } }, "sha512-wWXOCkF0MauMW3wfND5vNF9Dn9n8nfHV5k6F9qv9DWt9GrdzTWbV6dviXubDahMyVSiX3OrkupUBTRRT+dENlg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.21", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.21" } }, "sha512-LX+FGMZnhR2KQZ0idHH1+UwlXvkOl6P8w3Gne4TtjvUCt3QjG9FKIuP9JD3MAmEEkwGt0SoAPHPJEGTjUl3ydg=="], "@better-auth/telemetry": ["@better-auth/telemetry@1.4.21", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.21" } }, "sha512-LX+FGMZnhR2KQZ0idHH1+UwlXvkOl6P8w3Gne4TtjvUCt3QjG9FKIuP9JD3MAmEEkwGt0SoAPHPJEGTjUl3ydg=="],

24
drizzle.config.local.ts Normal file
View File

@@ -0,0 +1,24 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { defineConfig } from 'drizzle-kit';
const localD1Dir = '.wrangler/state/v3/d1/miniflare-D1DatabaseObject';
const localD1File = readdirSync(localD1Dir).find(
(file) => file.endsWith('.sqlite') && file !== 'metadata.sqlite',
);
if (!localD1File) {
throw new Error(`No local D1 sqlite file found in ${localD1Dir}`);
}
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: join(localD1Dir, localD1File),
},
verbose: true,
strict: true,
});

View File

@@ -0,0 +1,28 @@
CREATE TABLE `passkey` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`created_at` integer,
`aaguid` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);
--> statement-breakpoint
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);
--> statement-breakpoint
ALTER TABLE `user` ADD COLUMN `account_number` text NOT NULL DEFAULT '';
--> statement-breakpoint
ALTER TABLE `user` ADD COLUMN `is_anonymous` integer DEFAULT false;
--> statement-breakpoint
UPDATE `user`
SET `account_number` = substr('0000000000000000' || abs(random()), -16, 16)
WHERE `account_number` = '';
--> statement-breakpoint
CREATE UNIQUE INDEX `user_account_number_unique` ON `user` (`account_number`);

View File

@@ -0,0 +1,18 @@
CREATE TABLE `passkey` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`created_at` integer,
`aaguid` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);
--> statement-breakpoint
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);

View File

@@ -0,0 +1,540 @@
{
"version": "6",
"dialect": "sqlite",
"id": "773dd59c-33d4-4b87-8541-dda2e9151482",
"prevId": "7c532ee7-a504-49a0-91fa-c0206fa1f3b0",
"tables": {
"task": {
"name": "task",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_userId_idx": {
"name": "passkey_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"passkey_credentialID_idx": {
"name": "passkey_credentialID_idx",
"columns": [
"credential_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_number": {
"name": "account_number",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_anonymous": {
"name": "is_anonymous",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_account_number_unique": {
"name": "user_account_number_unique",
"columns": [
"account_number"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1775242876895, "when": 1775242876895,
"tag": "0001_curved_sunspot", "tag": "0001_curved_sunspot",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1775246437951,
"tag": "0002_account_number_auth",
"breakpoints": true
} }
] ]
} }

View File

@@ -20,6 +20,7 @@
"db:migrate:prod": "drizzle-kit migrate", "db:migrate:prod": "drizzle-kit migrate",
"db:migrate:dev": "bun wrangler d1 migrations apply lofi_db --local", "db:migrate:dev": "bun wrangler d1 migrations apply lofi_db --local",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:studio:dev": "drizzle-kit studio --config drizzle.config.local.ts",
"auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
}, },
"devDependencies": { "devDependencies": {
@@ -48,10 +49,8 @@
"wrangler": "^4.63.0", "wrangler": "^4.63.0",
"better-auth": "~1.4.21", "better-auth": "~1.4.21",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"mode-watcher": "^1.0.5" "mode-watcher": "^1.0.5",
"@better-auth/passkey": "~1.4.21"
}, },
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5"
"dependencies": {
"@better-auth/passkey": "^1.5.6"
}
} }

5
src/app.d.ts vendored
View File

@@ -1,6 +1,9 @@
import type { User, Session } from 'better-auth/minimal'; import type { auth } from '$lib/server/auth';
import { createAuth } from '$lib/server/auth'; import { createAuth } from '$lib/server/auth';
type Session = typeof auth.$Infer.Session.session;
type User = typeof auth.$Infer.Session.user;
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {

7
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createAuthClient } from 'better-auth/svelte';
import { passkeyClient } from '@better-auth/passkey/client';
export const authClient = createAuthClient({
plugins: [passkeyClient()],
});

View File

@@ -1 +1 @@
// place files you want to import through the `$lib` alias in this folder. export { authClient } from './auth-client';

View File

@@ -1,15 +1,92 @@
import { betterAuth } from 'better-auth'; import { APIError, betterAuth, type BetterAuthPlugin } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { createAuthEndpoint } from 'better-auth/api';
import { setSessionCookie } from 'better-auth/cookies';
import { anonymous } from 'better-auth/plugins';
import { sveltekitCookies } from 'better-auth/svelte-kit'; import { sveltekitCookies } from 'better-auth/svelte-kit';
import type { D1Database } from '@cloudflare/workers-types'; import type { D1Database } from '@cloudflare/workers-types';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { getRequestEvent } from '$app/server'; import { getRequestEvent } from '$app/server';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { passkey } from '@better-auth/passkey';
import * as z from 'zod';
const generateAccountNumber = () =>
Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => (value % 10).toString()).join('');
const accountNumber = () =>
({
id: 'account-number',
endpoints: {
signInAccountNumber: createAuthEndpoint(
'/sign-in/account-number',
{
method: 'POST',
body: z.object({
accountNumber: z.string().length(16),
}),
},
async (ctx) => {
const user = (await ctx.context.adapter.findOne({
model: 'user',
where: [
{
field: 'accountNumber',
value: ctx.body.accountNumber,
},
],
})) as
| ({
id: string;
createdAt: Date;
updatedAt: Date;
email: string;
emailVerified: boolean;
name: string;
image?: string | null;
} & Record<string, any>)
| null;
if (!user) {
throw new APIError('UNAUTHORIZED', {
message: 'Invalid account number',
});
}
const session = await ctx.context.internalAdapter.createSession(user.id);
if (!session) {
throw new APIError('INTERNAL_SERVER_ERROR', {
message: 'Failed to create session',
});
}
await setSessionCookie(ctx, { session, user });
return ctx.json({
token: session.token,
user,
});
},
),
},
}) satisfies BetterAuthPlugin;
const authConfig = { const authConfig = {
baseURL: env.ORIGIN, baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET, secret: env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: true }, emailAndPassword: { enabled: false },
user: {
additionalFields: {
accountNumber: {
type: 'string',
required: true,
input: false,
unique: true,
fieldName: 'account_number',
defaultValue: generateAccountNumber,
},
},
},
logger: { logger: {
level: 'debug', level: 'debug',
}, },
@@ -19,6 +96,15 @@ const authConfig = {
}, },
}, },
plugins: [ plugins: [
anonymous({
generateName: () => 'Chillhop listener',
emailDomainName: 'accounts.chillhop.local',
}),
accountNumber(),
passkey({
rpID: new URL(env.ORIGIN).hostname,
rpName: 'Chillhop',
}),
sveltekitCookies(getRequestEvent), // make sure this is the last plugin in the array sveltekitCookies(getRequestEvent), // make sure this is the last plugin in the array
], ],
} satisfies Omit<Parameters<typeof betterAuth>[0], 'database'>; } satisfies Omit<Parameters<typeof betterAuth>[0], 'database'>;

View File

@@ -16,6 +16,8 @@ export const user = sqliteTable("user", {
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
isAnonymous: integer("is_anonymous", { mode: "boolean" }).default(false),
account_number: text("account_number").notNull().unique(),
}); });
export const session = sqliteTable( export const session = sqliteTable(
@@ -87,9 +89,33 @@ export const verification = sqliteTable(
(table) => [index("verification_identifier_idx").on(table.identifier)], (table) => [index("verification_identifier_idx").on(table.identifier)],
); );
export const passkey = sqliteTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
createdAt: integer("created_at", { mode: "timestamp_ms" }),
aaguid: text("aaguid"),
},
(table) => [
index("passkey_userId_idx").on(table.userId),
index("passkey_credentialID_idx").on(table.credentialID),
],
);
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({ many }) => ({
sessions: many(session), sessions: many(session),
accounts: many(account), accounts: many(account),
passkeys: many(passkey),
})); }));
export const sessionRelations = relations(session, ({ one }) => ({ export const sessionRelations = relations(session, ({ one }) => ({
@@ -105,3 +131,10 @@ export const accountRelations = relations(account, ({ one }) => ({
references: [user.id], references: [user.id],
}), }),
})); }));
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}));

View File

@@ -1,12 +1,35 @@
<script lang="ts"> <script lang="ts">
import { authClient } from '$lib';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
let { data }: { data: PageServerData } = $props(); let { data }: { data: PageServerData } = $props();
let passkeyMessage = $state('');
let addingPasskey = $state(false);
const addPasskey = async () => {
addingPasskey = true;
passkeyMessage = '';
const result = await authClient.passkey.addPasskey({
name: 'Primary passkey',
authenticatorAttachment: 'platform',
});
addingPasskey = false;
passkeyMessage = result.error
? result.error.message || 'Failed to add passkey'
: 'Passkey added to your account.';
};
</script> </script>
<h1>Hi, {data.user.name}!</h1> <h1>Hi, {data.user.name}!</h1>
<p>Your user ID is {data.user.id}.</p> <p>Your user ID is {data.user.id}.</p>
<p>Your account number is {data.user.accountNumber}.</p>
<button onclick={addPasskey} disabled={addingPasskey}>
{addingPasskey ? 'Waiting for passkey...' : 'Add a passkey'}
</button>
<p>{passkeyMessage}</p>
<form method="post" action="?/signOut" use:enhance> <form method="post" action="?/signOut" use:enhance>
<button>Sign out</button> <button>Sign out</button>
</form> </form>

View File

@@ -21,51 +21,36 @@ export const load: PageServerLoad = (event) => {
}; };
export const actions: Actions = { export const actions: Actions = {
signInEmail: async (event) => { signInAccountNumber: async (event) => {
const { auth } = event.locals; const { auth } = event.locals;
const formData = await event.request.formData(); const formData = await event.request.formData();
const email = formData.get('email')?.toString() ?? ''; const accountNumber = formData.get('accountNumber')?.toString().replace(/\D/g, '') ?? '';
const password = formData.get('password')?.toString() ?? '';
try { try {
await auth.api.signInEmail({ await auth.api.signInAccountNumber({
body: { body: {
email, accountNumber,
password,
callbackURL: '/auth/verification-success',
}, },
}); });
} catch (error) { } catch (error) {
console.error('Demo Better Auth sign-in failed', { email, error }); console.error('Demo Better Auth account number sign-in failed', { accountNumber, error });
return fail(error instanceof APIError ? 400 : 500, { return fail(error instanceof APIError ? 400 : 500, {
message: getErrorMessage(error, 'Signin failed'), message: getErrorMessage(error, 'Account number sign-in failed'),
}); });
} }
return redirect(302, '/demo/better-auth'); return redirect(302, '/demo/better-auth');
}, },
signUpEmail: async (event) => { createAccount: async (event) => {
const { auth } = event.locals; const { auth } = event.locals;
const formData = await event.request.formData();
const email = formData.get('email')?.toString() ?? '';
const password = formData.get('password')?.toString() ?? '';
const name = formData.get('name')?.toString() ?? '';
try { try {
await auth.api.signUpEmail({ await auth.api.signInAnonymous();
body: {
email,
password,
name,
callbackURL: '/auth/verification-success',
},
});
} catch (error) { } catch (error) {
console.error('Demo Better Auth sign-up failed', { email, error }); console.error('Demo Better Auth account creation failed', { error });
return fail(error instanceof APIError ? 400 : 500, { return fail(error instanceof APIError ? 400 : 500, {
message: getErrorMessage(error, 'Registration failed'), message: getErrorMessage(error, 'Account creation failed'),
}); });
} }

View File

@@ -1,25 +1,43 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { authClient } from '$lib';
import type { ActionData } from './$types'; import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props(); let { form }: { form: ActionData } = $props();
let passkeyError = $state('');
let signingInWithPasskey = $state(false);
const signInWithPasskey = async () => {
signingInWithPasskey = true;
passkeyError = '';
const result = await authClient.signIn.passkey({
autoFill: true,
});
signingInWithPasskey = false;
if (result.error) {
passkeyError = result.error.message || 'Passkey sign-in failed';
return;
}
await goto('/demo/better-auth');
};
</script> </script>
<h1>Login</h1> <h1>Account Login</h1>
<form method="post" action="?/signInEmail" use:enhance> <form method="post" action="?/signInAccountNumber" use:enhance>
<label> <label>
Email Account number
<input type="email" name="email" /> <input name="accountNumber" inputmode="numeric" maxlength="16" autocomplete="webauthn" />
</label> </label>
<label> <button>Sign in with account number</button>
Password <button formaction="?/createAccount">Create account number</button>
<input type="password" name="password" />
</label>
<label>
Name (for registration)
<input name="name" />
</label>
<button>Login</button>
<button formaction="?/signUpEmail">Register</button>
</form> </form>
<p style="color: red">{form?.message ?? ''}</p> <p style="color: red">{form?.message ?? ''}</p>
<button onclick={signInWithPasskey} disabled={signingInWithPasskey}>
{signingInWithPasskey ? 'Waiting for passkey...' : 'Sign in with passkey'}
</button>
<p style="color: red">{passkeyError}</p>