From bf44099eb7c00fd6aec855270635c29f7ee3ffa8 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:23:40 +0900 Subject: [PATCH 01/20] install simplewebauthn --- bun.lockb | Bin 206108 -> 213299 bytes package.json | 2 ++ 2 files changed, 2 insertions(+) diff --git a/bun.lockb b/bun.lockb index ca38100f5ae41d5148d8c8a808bef651cb18f535..3c8bfe72447c28e1ed45f1c94aec944cee58d728 100755 GIT binary patch delta 40164 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�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 sPBDwcy#03 Date: Fri, 6 Dec 2024 08:24:33 +0900 Subject: [PATCH 02/20] =?UTF-8?q?passkey=20=E3=81=AE=20table,=20model=20?= =?UTF-8?q?=E5=AE=9A=E7=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0009_strong_firebird.sql | 18 ++ drizzle/meta/0009_snapshot.json | 287 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/server/db/schema.ts | 57 +++++- src/server/model/passkey.ts | 17 ++ 5 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 drizzle/0009_strong_firebird.sql create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 src/server/model/passkey.ts diff --git a/drizzle/0009_strong_firebird.sql b/drizzle/0009_strong_firebird.sql new file mode 100644 index 0000000..d54deaa --- /dev/null +++ b/drizzle/0009_strong_firebird.sql @@ -0,0 +1,18 @@ +CREATE TABLE `passkeys` ( + `id` text PRIMARY KEY NOT NULL, + `public_key` blob NOT NULL, + `user_id` text NOT NULL, + `webauthn_user_id` text NOT NULL, + `counter` blob NOT NULL, + `is_backed_up` integer NOT NULL, + `device_type` text NOT NULL, + `transports` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `passkeys_webauthn_user_id_unique` ON `passkeys` (`webauthn_user_id`);--> statement-breakpoint +CREATE INDEX `user_id_id_idx` ON `passkeys` (`user_id`,`id`);--> statement-breakpoint +CREATE INDEX `webauthn_user_id_id_idx` ON `passkeys` (`webauthn_user_id`,`id`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_id_webauthn_user_id_uk` ON `passkeys` (`user_id`,`webauthn_user_id`); \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..d6347e1 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,287 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "53c3c185-40b1-4ec0-89f7-008a03d2f001", + "prevId": "cee9c076-dfea-427d-b7d3-be89264ade6a", + "tables": { + "fragments": { + "name": "fragments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scrap_id": { + "name": "scrap_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "fragments_scrap_id_scraps_id_fk": { + "name": "fragments_scrap_id_scraps_id_fk", + "tableFrom": "fragments", + "tableTo": "scraps", + "columnsFrom": [ + "scrap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fragments_author_id_users_id_fk": { + "name": "fragments_author_id_users_id_fk", + "tableFrom": "fragments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webauthn_user_id": { + "name": "webauthn_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_backed_up": { + "name": "is_backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_webauthn_user_id_unique": { + "name": "passkeys_webauthn_user_id_unique", + "columns": [ + "webauthn_user_id" + ], + "isUnique": true + }, + "user_id_id_idx": { + "name": "user_id_id_idx", + "columns": [ + "user_id", + "id" + ], + "isUnique": false + }, + "webauthn_user_id_id_idx": { + "name": "webauthn_user_id_id_idx", + "columns": [ + "webauthn_user_id", + "id" + ], + "isUnique": false + }, + "user_id_webauthn_user_id_uk": { + "name": "user_id_webauthn_user_id_uk", + "columns": [ + "user_id", + "webauthn_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scraps": { + "name": "scraps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "scraps_owner_id_users_id_fk": { + "name": "scraps_owner_id_users_id_fk", + "tableFrom": "scraps", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d4d0033..9b8c571 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1733418611394, "tag": "0008_lean_spencer_smythe", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1733440947220, + "tag": "0009_strong_firebird", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 1d55190..5c2924d 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,7 +1,13 @@ import type { FragmentId } from '@/common/model/fragment' import type { UserId } from '@/common/model/user' +import type { + AuthenticatorTransportFuture, + Base64URLString, + CredentialDeviceType, +} from '@simplewebauthn/types' import { relations, sql } from 'drizzle-orm' -import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { index, int, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core' +import { blob } from 'drizzle-orm/sqlite-core/columns/blob' export const fragments = sqliteTable('fragments', { id: int().$type().primaryKey({ autoIncrement: true }), @@ -50,4 +56,53 @@ export const users = sqliteTable('users', { }) export const usersRelations = relations(users, ({ many }) => ({ scraps: many(scraps), + passkeys: many(passkeys), +})) + +// refs: https://simplewebauthn.dev/docs/packages/server#additional-data-structures +export const passkeys = sqliteTable( + 'passkeys', + { + id: text().$type().primaryKey(), + publicKey: blob('public_key', { mode: 'buffer' }) + .$type() + .notNull(), + userId: text('user_id') + .$type() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + webauthnUserId: text('webauthn_user_id') + .$type() + .notNull() + .unique(), + counter: blob({ mode: 'bigint' }).notNull(), + isBackedUp: int('is_backed_up', { mode: 'boolean' }).notNull(), + deviceType: text('device_type', { + enum: ['singleDevice', 'multiDevice'], + }) + .$type() + .notNull(), + transports: text('transports', { mode: 'json' }).$type< + AuthenticatorTransportFuture[] + >(), + createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`).notNull(), + lastUsedAt: text('last_used_at'), + }, + (table) => ({ + userIdWebauthnUserIdUk: unique('user_id_webauthn_user_id_uk').on( + table.userId, + table.webauthnUserId, + ), + userIdIdIdx: index('user_id_id_idx').on(table.userId, table.id), + webauthnUserIdIdIdx: index('webauthn_user_id_id_idx').on( + table.webauthnUserId, + table.id, + ), + }), +) +export const passkeysRelations = relations(passkeys, ({ one }) => ({ + user: one(users, { + fields: [passkeys.userId], + references: [users.id], + }), })) diff --git a/src/server/model/passkey.ts b/src/server/model/passkey.ts new file mode 100644 index 0000000..6829ae7 --- /dev/null +++ b/src/server/model/passkey.ts @@ -0,0 +1,17 @@ +import type { User } from '@/common/model/user' +import type { + AuthenticatorTransportFuture, + Base64URLString, + CredentialDeviceType, +} from '@simplewebauthn/types' + +export type Passkey = { + id: Base64URLString + publicKey: Uint8Array + user: User + webauthnUserId: Base64URLString + counter: bigint + isBackedUp: boolean + deviceType: CredentialDeviceType + transports: AuthenticatorTransportFuture[] | null +} From 4f747c69563964534c62e3f2008c9e7caf9faaa9 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:25:18 +0900 Subject: [PATCH 03/20] =?UTF-8?q?passkey,=20user=20=E3=81=AE=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/repository/passkey/index.ts | 80 ++++++++++++++++++++++++++ src/server/repository/user.ts | 24 ++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/server/repository/passkey/index.ts create mode 100644 src/server/repository/user.ts diff --git a/src/server/repository/passkey/index.ts b/src/server/repository/passkey/index.ts new file mode 100644 index 0000000..11941ad --- /dev/null +++ b/src/server/repository/passkey/index.ts @@ -0,0 +1,80 @@ +import type { UserId } from '@/common/model/user' +import * as schema from '@/server/db/schema' +import type { Passkey } from '@/server/model/passkey' +import type { DrizzleD1Database } from 'drizzle-orm/d1' + +export interface IPasskeyRepository { + findByUserId(userId: UserId): Promise + findByWebauthnUserIdAndUserId( + webauthnUserId: Passkey['webauthnUserId'], + userId: UserId, + ): Promise + save(passkey: Passkey): Promise +} + +export class PasskeyRepository implements IPasskeyRepository { + constructor(private db: DrizzleD1Database) {} + + async findByUserId(userId: UserId) { + const user = await this.db.query.users.findFirst({ + where: (users, { eq }) => eq(users.id, userId), + with: { passkeys: true }, + }) + + return ( + user?.passkeys.map((p) => ({ + ...p, + user: { + id: user.id, + }, + })) ?? [] + ) + } + + async findByWebauthnUserIdAndUserId( + webauthnUserId: Passkey['webauthnUserId'], + userId: UserId, + ) { + const passkey = await this.db.query.passkeys.findFirst({ + where: (passkeys, { and, eq }) => + and( + eq(passkeys.webauthnUserId, webauthnUserId), + eq(passkeys.userId, userId), + ), + with: { user: true }, + }) + if (!passkey) return null + + return { + ...passkey, + } + } + + async save(passkey: Passkey) { + // upsert + await this.db + .insert(schema.passkeys) + .values({ + id: passkey.id, + publicKey: passkey.publicKey, + userId: passkey.user.id, + webauthnUserId: passkey.webauthnUserId, + counter: passkey.counter, + isBackedUp: passkey.isBackedUp, + deviceType: passkey.deviceType, + transports: passkey.transports, + }) + .onConflictDoUpdate({ + target: schema.passkeys.id, + set: { + publicKey: passkey.publicKey, + userId: passkey.user.id, + webauthnUserId: passkey.webauthnUserId, + counter: passkey.counter, + isBackedUp: passkey.isBackedUp, + deviceType: passkey.deviceType, + transports: passkey.transports, + }, + }) + } +} diff --git a/src/server/repository/user.ts b/src/server/repository/user.ts new file mode 100644 index 0000000..337562c --- /dev/null +++ b/src/server/repository/user.ts @@ -0,0 +1,24 @@ +import type { User, UserId } from '@/common/model/user' +import type * as schema from '@/server/db/schema' +import type { DrizzleD1Database } from 'drizzle-orm/d1' + +export interface IUserRepository { + find(userId: UserId): Promise +} + +export class UserRepository implements IUserRepository { + constructor(private db: DrizzleD1Database) {} + + async find(userId: UserId) { + const rows = await this.db.query.users.findFirst({ + where: (users, { eq }) => eq(users.id, userId), + }) + if (!rows) { + return null + } + + return { + ...rows, + } + } +} From 9605c72fc78e1602395c27593cc7943ac4cf90db Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:27:20 +0900 Subject: [PATCH 04/20] =?UTF-8?q?passkey=20=E7=99=BB=E9=8C=B2=E5=87=A6?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/passkey/registrationSession.ts | 49 ++++++++ src/server/service/passkey/registration.ts | 119 ++++++++++++++++++ src/server/service/passkey/rp.ts | 3 + 3 files changed, 171 insertions(+) create mode 100644 src/server/repository/passkey/registrationSession.ts create mode 100644 src/server/service/passkey/registration.ts create mode 100644 src/server/service/passkey/rp.ts diff --git a/src/server/repository/passkey/registrationSession.ts b/src/server/repository/passkey/registrationSession.ts new file mode 100644 index 0000000..87d1815 --- /dev/null +++ b/src/server/repository/passkey/registrationSession.ts @@ -0,0 +1,49 @@ +import type { UserId } from '@/common/model/user' +import { z } from 'zod' + +const passkeyRegistrationSessionSchema = z.object({ + challenge: z.string(), + webauthnUserId: z.string(), +}) +export type RegistrationSession = z.infer< + typeof passkeyRegistrationSessionSchema +> + +export interface IPasskeyRegistrationSessionRepository { + store(userId: UserId, session: RegistrationSession): Promise + load(userId: UserId): Promise +} + +export class KVPasskeyRegistrationSessionRepository + implements IPasskeyRegistrationSessionRepository +{ + constructor( + private kv: KVNamespace, + private ttl?: number, + ) {} + + async store(userId: UserId, session: RegistrationSession) { + const key = this.formatKey(userId) + const options = this.ttl ? { expirationTtl: this.ttl } : {} + await this.kv.put(key, JSON.stringify(session), options) + } + + async load(userId: UserId) { + const key = this.formatKey(userId) + const raw = await this.kv.get(key) + if (!raw) { + return null + } + + try { + return passkeyRegistrationSessionSchema.parse(JSON.parse(raw)) + } catch (e) { + console.error(e) + return null + } + } + + private formatKey(userId: UserId) { + return `passkey:registration:${userId}` + } +} diff --git a/src/server/service/passkey/registration.ts b/src/server/service/passkey/registration.ts new file mode 100644 index 0000000..82e73bd --- /dev/null +++ b/src/server/service/passkey/registration.ts @@ -0,0 +1,119 @@ +import type { UserId } from '@/common/model/user' +import type { Passkey } from '@/server/model/passkey' +import type { IPasskeyRepository } from '@/server/repository/passkey' +import type { IPasskeyRegistrationSessionRepository } from '@/server/repository/passkey/registrationSession' +import type { IUserRepository } from '@/server/repository/user' +import { rpID, rpName } from '@/server/service/passkey/rp' +import { + type VerifiedRegistrationResponse, + generateRegistrationOptions, + verifyRegistrationResponse, +} from '@simplewebauthn/server' +import type { + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/types' + +export type GenerateOptionsInput = { + userId: UserId +} + +export type VerifyInput = { + userId: UserId + body: RegistrationResponseJSON +} + +export interface IPasskeyRegistrationService { + generateOptions( + input: GenerateOptionsInput, + ): Promise + verify( + input: VerifyInput, + ): Promise> +} + +export class PasskeyRegistrationService implements IPasskeyRegistrationService { + constructor( + private userRepo: IUserRepository, + private passkeyRepo: IPasskeyRepository, + private passkeyRegistrationSessionRepo: IPasskeyRegistrationSessionRepository, + ) {} + + // refs: https://simplewebauthn.dev/docs/packages/server#1-generate-registration-options + async generateOptions(input: GenerateOptionsInput) { + const user = await this.userRepo.find(input.userId) + if (!user) { + throw new Error('User not found') + } + const userPasskeys = await this.passkeyRepo.findByUserId(user.id) + + const options: PublicKeyCredentialCreationOptionsJSON = + await generateRegistrationOptions({ + rpName, + rpID, + userName: user.id, + // Don't prompt users for additional information about the authenticator + attestationType: 'none', + excludeCredentials: userPasskeys.map((passkey) => ({ + id: passkey.id, + ...(passkey.transports && { transports: passkey.transports }), + })), + authenticatorSelection: { + // Defaults + residentKey: 'preferred', + userVerification: 'preferred', + // Optional + authenticatorAttachment: 'platform', + }, + }) + + await this.passkeyRegistrationSessionRepo.store(user.id, { + challenge: options.challenge, + webauthnUserId: options.user.id, + }) + + return options + } + + // refs: https://simplewebauthn.dev/docs/packages/server#2-verify-registration-response + async verify(input: VerifyInput) { + const user = await this.userRepo.find(input.userId) + if (!user) { + throw new Error('User not found') + } + const registrationSession = await this.passkeyRegistrationSessionRepo.load( + user.id, + ) + if (!registrationSession) { + throw new Error('Registration session not found') + } + + const verification = await verifyRegistrationResponse({ + response: input.body, + expectedChallenge: registrationSession.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + }) + + const { verified } = verification + if (verified) { + const { credential, credentialDeviceType, credentialBackedUp } = + // biome-ignore lint/style/noNonNullAssertion: + verification.registrationInfo! + + const newPasskey: Passkey = { + id: credential.id, + publicKey: credential.publicKey, + user: user, + webauthnUserId: registrationSession.webauthnUserId, + counter: BigInt(credential.counter), + isBackedUp: credentialBackedUp, + deviceType: credentialDeviceType, + transports: credential.transports ?? null, + } + await this.passkeyRepo.save(newPasskey) + } + + return { verified } + } +} diff --git a/src/server/service/passkey/rp.ts b/src/server/service/passkey/rp.ts new file mode 100644 index 0000000..8c42329 --- /dev/null +++ b/src/server/service/passkey/rp.ts @@ -0,0 +1,3 @@ +export const rpName = 'Scrap' +export const rpID = 'localhost' +export const origin = `http://${rpID}:5173` From 8653f15918ba4e78cef49dffabc5ada34e79127e Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:12:49 +0900 Subject: [PATCH 05/20] =?UTF-8?q?passkey=20=E8=AA=8D=E8=A8=BC=E5=87=A6?= =?UTF-8?q?=E7=90=86=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../passkey/authenticationSession.ts | 45 +++++++ src/server/service/passkey/authentication.ts | 110 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/server/repository/passkey/authenticationSession.ts create mode 100644 src/server/service/passkey/authentication.ts diff --git a/src/server/repository/passkey/authenticationSession.ts b/src/server/repository/passkey/authenticationSession.ts new file mode 100644 index 0000000..922d67d --- /dev/null +++ b/src/server/repository/passkey/authenticationSession.ts @@ -0,0 +1,45 @@ +import type { UserId } from '@/common/model/user' +import { z } from 'zod' + +const authenticationSessionSchema = z.object({ + challenge: z.string(), +}) +export type AuthenticationSession = z.infer + +export interface IPasskeyAuthenticationSessionRepository { + store(userId: UserId, session: AuthenticationSession): Promise + load(userId: UserId): Promise +} + +export class KVPasskeyAuthenticationSessionRepository + implements IPasskeyAuthenticationSessionRepository +{ + constructor( + private kv: KVNamespace, + private ttl?: number, + ) {} + + async store(userId: UserId, session: AuthenticationSession): Promise { + const key = this.formatKey(userId) + const options = this.ttl ? { expirationTtl: this.ttl } : {} + await this.kv.put(key, JSON.stringify(session), options) + } + + async load(userId: UserId): Promise { + const key = this.formatKey(userId) + const raw = await this.kv.get(key) + if (raw === null) { + return null + } + + try { + return authenticationSessionSchema.parse(JSON.parse(raw)) + } catch (e) { + return null + } + } + + private formatKey(userId: UserId) { + return `passkey:authentication:${userId}` + } +} diff --git a/src/server/service/passkey/authentication.ts b/src/server/service/passkey/authentication.ts new file mode 100644 index 0000000..877d74b --- /dev/null +++ b/src/server/service/passkey/authentication.ts @@ -0,0 +1,110 @@ +import type { UserId } from '@/common/model/user' +import type { IPasskeyRepository } from '@/server/repository/passkey' +import type { IPasskeyAuthenticationSessionRepository } from '@/server/repository/passkey/authenticationSession' +import type { IUserRepository } from '@/server/repository/user' +import { origin, rpID } from '@/server/service/passkey/rp' +import { + type VerifiedAuthenticationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from '@simplewebauthn/server' +import type { + AuthenticationResponseJSON, + PublicKeyCredentialRequestOptionsJSON, +} from '@simplewebauthn/types' + +export type GenerateOptionsInput = { + userId: UserId +} +export type VerifyInput = { + userId: UserId + body: AuthenticationResponseJSON +} + +export type IPasskeyAuthenticationService = { + generateOptions( + input: GenerateOptionsInput, + ): Promise + verify( + input: VerifyInput, + ): Promise> +} + +export class PasskeyAuthenticationService + implements IPasskeyAuthenticationService +{ + constructor( + private userRepo: IUserRepository, + private passkeyRepo: IPasskeyRepository, + private authenticationSessionRepo: IPasskeyAuthenticationSessionRepository, + ) {} + + async generateOptions( + input: GenerateOptionsInput, + ): Promise { + const user = await this.userRepo.find(input.userId) + if (!user) { + throw new Error('User not found') + } + const userPasskeys = await this.passkeyRepo.findByUserId(user.id) + + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials: userPasskeys.map((passkey) => ({ + id: passkey.id, + transports: passkey.transports ?? undefined, + })), + }) + + await this.authenticationSessionRepo.store(user.id, { + challenge: options.challenge, + }) + + return options + } + + async verify( + input: VerifyInput, + ): Promise> { + const user = await this.userRepo.find(input.userId) + if (!user) { + throw new Error('User not found') + } + const authenticationSession = await this.authenticationSessionRepo.load( + user.id, + ) + if (!authenticationSession) { + throw new Error('No authentication session found') + } + const passkey = await this.passkeyRepo.findByWebauthnUserIdAndUserId( + input.body.id, + user.id, + ) + if (!passkey) { + throw new Error('Passkey not found') + } + + const verification = await verifyAuthenticationResponse({ + response: input.body, + expectedChallenge: authenticationSession.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: passkey.id, + publicKey: passkey.publicKey, + counter: Number(passkey.counter), + transports: passkey.transports ?? undefined, + }, + }) + + const { verified } = verification + if (verified) { + // biome-ignore lint/style/noNonNullAssertion: + const { newCounter } = verification.authenticationInfo! + passkey.counter = BigInt(newCounter) + await this.passkeyRepo.save(passkey) + } + + return { verified } + } +} From d9fa33f275f97ad1a127685a6f5e20b44e9126df Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:06:06 +0900 Subject: [PATCH 06/20] =?UTF-8?q?/auth=20=E3=81=AE=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E5=88=86=E5=89=B2=20unapply=20no=20basepath?= =?UTF-8?q?=20mv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/routes/auth/index.ts | 6 ++++++ src/server/routes/{auth.ts => auth/password.ts} | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 src/server/routes/auth/index.ts rename src/server/routes/{auth.ts => auth/password.ts} (97%) diff --git a/src/server/routes/auth/index.ts b/src/server/routes/auth/index.ts new file mode 100644 index 0000000..8985891 --- /dev/null +++ b/src/server/routes/auth/index.ts @@ -0,0 +1,6 @@ +import passwordAuth from '@/server/routes/auth/password' +import { Hono } from 'hono' + +const auth = new Hono().basePath('/auth').route('/', passwordAuth) + +export default auth diff --git a/src/server/routes/auth.ts b/src/server/routes/auth/password.ts similarity index 97% rename from src/server/routes/auth.ts rename to src/server/routes/auth/password.ts index 43bb3f9..dca4a70 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth/password.ts @@ -17,9 +17,8 @@ import { z } from 'zod' const DUMMY_PASSWORD_HASH = '$2a$10$jl8KgQv7CRjy2K5rhoiLmOf6Xa4UTltGzdbn6vYDWGQlSuzXT4CpK' -const auth = honoFactory +const passwordAuth = honoFactory .createApp() - .basePath('/auth') .post( '/login', zValidator( @@ -98,4 +97,4 @@ const auth = honoFactory return c.body(null, 204) }) -export default auth +export default passwordAuth From 243370ac639e4f3f67135dbd6214fe586ca17b51 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:57:20 +0900 Subject: [PATCH 07/20] =?UTF-8?q?=E7=99=BB=E9=8C=B2=E5=87=A6=E7=90=86:=20?= =?UTF-8?q?=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E5=A4=89?= =?UTF-8?q?=E6=9B=B4,=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/service/passkey/registration.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/service/passkey/registration.ts b/src/server/service/passkey/registration.ts index 82e73bd..646cdca 100644 --- a/src/server/service/passkey/registration.ts +++ b/src/server/service/passkey/registration.ts @@ -20,7 +20,7 @@ export type GenerateOptionsInput = { export type VerifyInput = { userId: UserId - body: RegistrationResponseJSON + registrationResponse: RegistrationResponseJSON } export interface IPasskeyRegistrationService { @@ -60,9 +60,8 @@ export class PasskeyRegistrationService implements IPasskeyRegistrationService { })), authenticatorSelection: { // Defaults - residentKey: 'preferred', + residentKey: 'required', userVerification: 'preferred', - // Optional authenticatorAttachment: 'platform', }, }) @@ -89,10 +88,11 @@ export class PasskeyRegistrationService implements IPasskeyRegistrationService { } const verification = await verifyRegistrationResponse({ - response: input.body, + response: input.registrationResponse, expectedChallenge: registrationSession.challenge, expectedOrigin: origin, expectedRPID: rpID, + requireUserVerification: false, }) const { verified } = verification From 0a778846b302b92b9ae3e9eab26dc7b71b3c1860 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 03:20:00 +0900 Subject: [PATCH 08/20] =?UTF-8?q?passkey=E8=AA=8D=E8=A8=BC=E7=94=A8?= =?UTF-8?q?=E3=81=AB=E4=BF=AE=E6=AD=A3=20const?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/constant/passkey.ts | 1 + src/server/repository/passkey/index.ts | 16 +----- src/server/service/passkey/authentication.ts | 59 ++++---------------- 3 files changed, 16 insertions(+), 60 deletions(-) create mode 100644 src/server/constant/passkey.ts diff --git a/src/server/constant/passkey.ts b/src/server/constant/passkey.ts new file mode 100644 index 0000000..3a45c9a --- /dev/null +++ b/src/server/constant/passkey.ts @@ -0,0 +1 @@ +export const PASSKEY_REGISTRATION_SESSION_TTL = 60 * 5 // 5 minutes diff --git a/src/server/repository/passkey/index.ts b/src/server/repository/passkey/index.ts index 11941ad..fe4b5bc 100644 --- a/src/server/repository/passkey/index.ts +++ b/src/server/repository/passkey/index.ts @@ -5,10 +5,7 @@ import type { DrizzleD1Database } from 'drizzle-orm/d1' export interface IPasskeyRepository { findByUserId(userId: UserId): Promise - findByWebauthnUserIdAndUserId( - webauthnUserId: Passkey['webauthnUserId'], - userId: UserId, - ): Promise + find(credentialId: Passkey['id']): Promise save(passkey: Passkey): Promise } @@ -31,16 +28,9 @@ export class PasskeyRepository implements IPasskeyRepository { ) } - async findByWebauthnUserIdAndUserId( - webauthnUserId: Passkey['webauthnUserId'], - userId: UserId, - ) { + async find(credentialId: Passkey['id']) { const passkey = await this.db.query.passkeys.findFirst({ - where: (passkeys, { and, eq }) => - and( - eq(passkeys.webauthnUserId, webauthnUserId), - eq(passkeys.userId, userId), - ), + where: (passkeys, { eq }) => eq(passkeys.id, credentialId), with: { user: true }, }) if (!passkey) return null diff --git a/src/server/service/passkey/authentication.ts b/src/server/service/passkey/authentication.ts index 877d74b..fd2058b 100644 --- a/src/server/service/passkey/authentication.ts +++ b/src/server/service/passkey/authentication.ts @@ -1,7 +1,4 @@ -import type { UserId } from '@/common/model/user' import type { IPasskeyRepository } from '@/server/repository/passkey' -import type { IPasskeyAuthenticationSessionRepository } from '@/server/repository/passkey/authenticationSession' -import type { IUserRepository } from '@/server/repository/user' import { origin, rpID } from '@/server/service/passkey/rp' import { type VerifiedAuthenticationResponse, @@ -13,12 +10,10 @@ import type { PublicKeyCredentialRequestOptionsJSON, } from '@simplewebauthn/types' -export type GenerateOptionsInput = { - userId: UserId -} +export type GenerateOptionsInput = Record export type VerifyInput = { - userId: UserId - body: AuthenticationResponseJSON + authenticationResponse: AuthenticationResponseJSON + expectedChallenge: string } export type IPasskeyAuthenticationService = { @@ -33,60 +28,29 @@ export type IPasskeyAuthenticationService = { export class PasskeyAuthenticationService implements IPasskeyAuthenticationService { - constructor( - private userRepo: IUserRepository, - private passkeyRepo: IPasskeyRepository, - private authenticationSessionRepo: IPasskeyAuthenticationSessionRepository, - ) {} + constructor(private passkeyRepo: IPasskeyRepository) {} async generateOptions( - input: GenerateOptionsInput, + _input: GenerateOptionsInput, ): Promise { - const user = await this.userRepo.find(input.userId) - if (!user) { - throw new Error('User not found') - } - const userPasskeys = await this.passkeyRepo.findByUserId(user.id) - - const options = await generateAuthenticationOptions({ + return await generateAuthenticationOptions({ rpID, - allowCredentials: userPasskeys.map((passkey) => ({ - id: passkey.id, - transports: passkey.transports ?? undefined, - })), + userVerification: 'preferred', + allowCredentials: [], }) - - await this.authenticationSessionRepo.store(user.id, { - challenge: options.challenge, - }) - - return options } async verify( input: VerifyInput, ): Promise> { - const user = await this.userRepo.find(input.userId) - if (!user) { - throw new Error('User not found') - } - const authenticationSession = await this.authenticationSessionRepo.load( - user.id, - ) - if (!authenticationSession) { - throw new Error('No authentication session found') - } - const passkey = await this.passkeyRepo.findByWebauthnUserIdAndUserId( - input.body.id, - user.id, - ) + const passkey = await this.passkeyRepo.find(input.authenticationResponse.id) if (!passkey) { throw new Error('Passkey not found') } const verification = await verifyAuthenticationResponse({ - response: input.body, - expectedChallenge: authenticationSession.challenge, + response: input.authenticationResponse, + expectedChallenge: input.expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, credential: { @@ -95,6 +59,7 @@ export class PasskeyAuthenticationService counter: Number(passkey.counter), transports: passkey.transports ?? undefined, }, + requireUserVerification: false, }) const { verified } = verification From 2b01eb01002bd546a21f1d8ee4fe2dd88d38849a Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:18:26 +0900 Subject: [PATCH 09/20] =?UTF-8?q?route=20=E3=81=AE=E5=AE=9F=E8=A3=85=20cho?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/routes/auth/index.ts | 6 +- src/server/routes/auth/passkey.ts | 106 ++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/server/routes/auth/passkey.ts diff --git a/src/server/routes/auth/index.ts b/src/server/routes/auth/index.ts index 8985891..4950b21 100644 --- a/src/server/routes/auth/index.ts +++ b/src/server/routes/auth/index.ts @@ -1,6 +1,10 @@ +import passkeyAuth from '@/server/routes/auth/passkey' import passwordAuth from '@/server/routes/auth/password' import { Hono } from 'hono' -const auth = new Hono().basePath('/auth').route('/', passwordAuth) +const auth = new Hono() + .basePath('/auth') + .route('/', passwordAuth) + .route('/passkey', passkeyAuth) export default auth diff --git a/src/server/routes/auth/passkey.ts b/src/server/routes/auth/passkey.ts new file mode 100644 index 0000000..d4cc202 --- /dev/null +++ b/src/server/routes/auth/passkey.ts @@ -0,0 +1,106 @@ +import { PASSKEY_REGISTRATION_SESSION_TTL } from '@/server/constant/passkey' +import type { AppEnv } from '@/server/env' +import { sessionAuthMiddleware } from '@/server/middleware/sessionAuth' +import { PasskeyRepository } from '@/server/repository/passkey' +import { KVPasskeyRegistrationSessionRepository } from '@/server/repository/passkey/registrationSession' +import { UserRepository } from '@/server/repository/user' +import { + type IPasskeyAuthenticationService, + PasskeyAuthenticationService, +} from '@/server/service/passkey/authentication' +import { + type IPasskeyRegistrationService, + PasskeyRegistrationService, +} from '@/server/service/passkey/registration' +import { honoFactory } from '@/server/utility/factory' +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/types' +import { getCookie, setCookie } from 'hono/cookie' +import { createMiddleware } from 'hono/factory' +import { HTTPException } from 'hono/http-exception' + +type AppEnvWithDeps = AppEnv & { + Variables: { + passkeyRegistrationService: IPasskeyRegistrationService + passkeyAuthenticationService: IPasskeyAuthenticationService + } +} + +const injectDeps = createMiddleware(async (c, next) => { + const userRepo = new UserRepository(c.var.db) + const passkeyRepo = new PasskeyRepository(c.var.db) + const regSessionRepo = new KVPasskeyRegistrationSessionRepository( + c.env.SESSION_KV, + PASSKEY_REGISTRATION_SESSION_TTL, + ) + + const regService = new PasskeyRegistrationService( + userRepo, + passkeyRepo, + regSessionRepo, + ) + const authService = new PasskeyAuthenticationService(passkeyRepo) + + c.set('passkeyRegistrationService', regService) + c.set('passkeyAuthenticationService', authService) + + await next() +}) + +const passkeyAuth = honoFactory + .createApp() + .use(injectDeps) + .get('/attestation/options', sessionAuthMiddleware, async (c) => { + const session = c.var.session + + const options = await c.var.passkeyRegistrationService.generateOptions({ + userId: session.userId, + }) + return c.json(options) + }) + .post('/attestation', sessionAuthMiddleware, async (c) => { + const session = c.var.session + const body = await c.req.json() + + const { verified } = await c.var.passkeyRegistrationService.verify({ + userId: session.userId, + registrationResponse: body, + }) + if (!verified) { + throw new HTTPException(400) + } + + return c.json(null, 201) + }) + .get('/assertion/options', async (c) => { + const options = await c.var.passkeyAuthenticationService.generateOptions({}) + + setCookie(c, 'ASSERTION_EXPECTED_CHALLENGE', options.challenge, { + httpOnly: true, + secure: import.meta.env.PROD, + sameSite: 'strict', + }) + + return c.json(options) + }) + .post('/assertion', async (c) => { + const expectedChallenge = getCookie(c, 'ASSERTION_EXPECTED_CHALLENGE') + if (!expectedChallenge) { + throw new HTTPException(400) + } + const body = await c.req.json() + + const { verified } = await c.var.passkeyAuthenticationService.verify({ + authenticationResponse: body, + expectedChallenge, + }) + if (!verified) { + throw new HTTPException(400) + } + + return c.json(null, 201) + }) + +export default passkeyAuth From 10a3b3c934dc62822aa5263e4c5033617b2d468a Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 20:14:16 +0900 Subject: [PATCH 10/20] =?UTF-8?q?counter=20=E3=82=92=20bigint=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=20int=20=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0010_pretty_genesis.sql | 23 ++ drizzle/meta/0010_snapshot.json | 287 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/server/db/schema.ts | 2 +- src/server/model/passkey.ts | 2 +- src/server/service/passkey/authentication.ts | 2 +- src/server/service/passkey/registration.ts | 4 +- 7 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 drizzle/0010_pretty_genesis.sql create mode 100644 drizzle/meta/0010_snapshot.json diff --git a/drizzle/0010_pretty_genesis.sql b/drizzle/0010_pretty_genesis.sql new file mode 100644 index 0000000..37b72c0 --- /dev/null +++ b/drizzle/0010_pretty_genesis.sql @@ -0,0 +1,23 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_passkeys` ( + `id` text PRIMARY KEY NOT NULL, + `public_key` blob NOT NULL, + `user_id` text NOT NULL, + `webauthn_user_id` text NOT NULL, + `counter` integer NOT NULL, + `is_backed_up` integer NOT NULL, + `device_type` text NOT NULL, + `transports` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_passkeys`("id", "public_key", "user_id", "webauthn_user_id", "counter", "is_backed_up", "device_type", "transports", "created_at", "last_used_at") SELECT "id", "public_key", "user_id", "webauthn_user_id", "counter", "is_backed_up", "device_type", "transports", "created_at", "last_used_at" FROM `passkeys`;--> statement-breakpoint +DROP TABLE `passkeys`;--> statement-breakpoint +ALTER TABLE `__new_passkeys` RENAME TO `passkeys`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `passkeys_webauthn_user_id_unique` ON `passkeys` (`webauthn_user_id`);--> statement-breakpoint +CREATE INDEX `user_id_id_idx` ON `passkeys` (`user_id`,`id`);--> statement-breakpoint +CREATE INDEX `webauthn_user_id_id_idx` ON `passkeys` (`webauthn_user_id`,`id`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_id_webauthn_user_id_uk` ON `passkeys` (`user_id`,`webauthn_user_id`); \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..10fc861 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,287 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6d0cbc30-40ff-4f84-816a-15f8f60ba37b", + "prevId": "53c3c185-40b1-4ec0-89f7-008a03d2f001", + "tables": { + "fragments": { + "name": "fragments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scrap_id": { + "name": "scrap_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "fragments_scrap_id_scraps_id_fk": { + "name": "fragments_scrap_id_scraps_id_fk", + "tableFrom": "fragments", + "tableTo": "scraps", + "columnsFrom": [ + "scrap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fragments_author_id_users_id_fk": { + "name": "fragments_author_id_users_id_fk", + "tableFrom": "fragments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webauthn_user_id": { + "name": "webauthn_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_backed_up": { + "name": "is_backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_webauthn_user_id_unique": { + "name": "passkeys_webauthn_user_id_unique", + "columns": [ + "webauthn_user_id" + ], + "isUnique": true + }, + "user_id_id_idx": { + "name": "user_id_id_idx", + "columns": [ + "user_id", + "id" + ], + "isUnique": false + }, + "webauthn_user_id_id_idx": { + "name": "webauthn_user_id_id_idx", + "columns": [ + "webauthn_user_id", + "id" + ], + "isUnique": false + }, + "user_id_webauthn_user_id_uk": { + "name": "user_id_webauthn_user_id_uk", + "columns": [ + "user_id", + "webauthn_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scraps": { + "name": "scraps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "scraps_owner_id_users_id_fk": { + "name": "scraps_owner_id_users_id_fk", + "tableFrom": "scraps", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9b8c571..12fb9c3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1733440947220, "tag": "0009_strong_firebird", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1734080866032, + "tag": "0010_pretty_genesis", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 5c2924d..1b1b740 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -75,7 +75,7 @@ export const passkeys = sqliteTable( .$type() .notNull() .unique(), - counter: blob({ mode: 'bigint' }).notNull(), + counter: int().notNull(), isBackedUp: int('is_backed_up', { mode: 'boolean' }).notNull(), deviceType: text('device_type', { enum: ['singleDevice', 'multiDevice'], diff --git a/src/server/model/passkey.ts b/src/server/model/passkey.ts index 6829ae7..ab3c738 100644 --- a/src/server/model/passkey.ts +++ b/src/server/model/passkey.ts @@ -10,7 +10,7 @@ export type Passkey = { publicKey: Uint8Array user: User webauthnUserId: Base64URLString - counter: bigint + counter: number isBackedUp: boolean deviceType: CredentialDeviceType transports: AuthenticatorTransportFuture[] | null diff --git a/src/server/service/passkey/authentication.ts b/src/server/service/passkey/authentication.ts index fd2058b..65003bf 100644 --- a/src/server/service/passkey/authentication.ts +++ b/src/server/service/passkey/authentication.ts @@ -66,7 +66,7 @@ export class PasskeyAuthenticationService if (verified) { // biome-ignore lint/style/noNonNullAssertion: const { newCounter } = verification.authenticationInfo! - passkey.counter = BigInt(newCounter) + passkey.counter = newCounter await this.passkeyRepo.save(passkey) } diff --git a/src/server/service/passkey/registration.ts b/src/server/service/passkey/registration.ts index 646cdca..47f35e1 100644 --- a/src/server/service/passkey/registration.ts +++ b/src/server/service/passkey/registration.ts @@ -3,7 +3,7 @@ import type { Passkey } from '@/server/model/passkey' import type { IPasskeyRepository } from '@/server/repository/passkey' import type { IPasskeyRegistrationSessionRepository } from '@/server/repository/passkey/registrationSession' import type { IUserRepository } from '@/server/repository/user' -import { rpID, rpName } from '@/server/service/passkey/rp' +import { origin, rpID, rpName } from '@/server/service/passkey/rp' import { type VerifiedRegistrationResponse, generateRegistrationOptions, @@ -106,7 +106,7 @@ export class PasskeyRegistrationService implements IPasskeyRegistrationService { publicKey: credential.publicKey, user: user, webauthnUserId: registrationSession.webauthnUserId, - counter: BigInt(credential.counter), + counter: credential.counter, isBackedUp: credentialBackedUp, deviceType: credentialDeviceType, transports: credential.transports ?? null, From 776e267f3a069235f47a64cba83ac5eefbe1344f Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 20:14:47 +0900 Subject: [PATCH 11/20] bun add @simplewebauthn/browser --- bun.lockb | Bin 213299 -> 213673 bytes package.json | 1 + 2 files changed, 1 insertion(+) diff --git a/bun.lockb b/bun.lockb index 3c8bfe72447c28e1ed45f1c94aec944cee58d728..f1dd0ef3b62f6c699438ea021b3bf09ea7802556 100755 GIT binary patch delta 34031 zcmeHwd3;S**Z(;ux#S>-gyd#G43!u{5^)n5t|15!QxP{BLaH5vnZqApr@y=s_t}ik55inK=s|S?vsu!pVh#5sJWYyz#s4B ziA|&skL6Q3l;nZ&7KGANU|nDcur@HoSrF;~y`g&nF9PcUdz&=epksdenDi;gG7>tc z?*ltCFdFCzyj4RG+<;RjXBMW86@=3@1)%}-d>7TOq%;WuE?^%b8UZ%|J%EdK!$}#L zS?MU?SS>BXx~QEW;)|iz2VREG=|2W?f#blt14jYzue1lge1O{#*$}u=$LT;XAzmmw zjB0a%Y3Y-)awiBvT6*5Zlq^9ALPAgQZ=rDRiK%*pGV}@#0CI&>Q*$Tg6beF|a#Qpy zxr)ZJb^4UT^qe$77@d-dyaZt*c&@HH>c*))1o{F?QFK$F3+l%G(jR&-a3A9Rfg6Ba zyG20!D=o$s_p_pxQ;aHd^M3_47lfqJ>j=mo`e77!VDBN83+$-t?m*7?DtK<*Ss)MC zEp!rBs8Yw*fgHa>$0f=o$+P4*thsrU8*8OZM>W`Le-lA?0=N>$xlac2C=CL(1cm^) znYDlcz#OFGW_Y5`Z07{zW?ls{|4lQkHs^qx|6U-Hjw>CE+)$&^vp`OepPrqPKVA^5 zl2)Po^pw;>^!E#>4OcXxmBv><=WcldcHG>RqBdQ|XXWOkz&;+e;`u!`HzyaW;GjInmcb?8*LVk0CKJafLzUS1?dH;u#SYzlk1nZS}s*UuJ%tTg-6XfNGt6Ebk46% zdkqTJq4Uf>53CCuZqcgd0iCPZD_pC>G9brWfNb9k*bulmLd(Bg$J*ez z98pVOQkt4oFoo;*Lr1O7lOwgx`v}Mxr31O`O{26yDohzYI%RZLdMfO>vty&R3aeA2 zU1q@|=9>78CMdSYf)no!zZ zD>56%ZL|SBfW34+RM-80T!mUdw!7U;OZPR9?cW7*{9#?+0_3)r>HJh6(v_5E=mvv< z+}19--WtdmcmlZq3CNv#qpMcHc_7E1)b)KpPCppPgSW4bX4gJm;~N3FZ<+&nqSx=s z^QgqJ-bU#?5ewuDvNN;UI}5F$v%B#Hawob0xpQmi_=i;+gr9Wm)L+wE0^tivKM^$h zJJ7k{YS^h0djK2+2DUVKptf*#1K9zr0diOEh5;A&ERd&VN@{L?4l-K@oik9B<25}~ zmJAjIxUABAAXocIApA*bCm{QuM!=@Pi8*PRshNWC>QHU{6y;9jT}$Yks8#Q2AeZtM zkoz&EpdF@LR(gsO;nGC>NU^!JDmj*<<-H%sw$B5(hGSC-awnr|cZO?yY)mF&(2dDp z48Ae8#uyr7YfLs{k{OdppG1zyVoVZaG8mJZ(m9W8T2`SAiXg4M+ceHSEqLyGbBI?61ZIZUW zUj_2=aG9*l&q5`lcB_&N@Q+;GT16U;12%wu2FQ!>e`%%}Ua7?Hj{eg#?LHdD%{^DD zb;CmF9?+)(xm&b`m81#(;WcEm?B8oxN3N0XfDK3duh!5$#m&_-;>iV?_lpJALoVS! zZf{#17cSHWv;@clIT6S%B~M9l_3Y}uSer#@K#tu7!`TxGKhD?eE(3K( z)Gi|{Wz46_A=f4)273>db&%@ArCJ}K1ah4Y0oB}r9KTY>yye=~k_O~b2LO3&t|K2F zn@f6rw^nFvd<{C+$`+{AM)&x&N)4C)s*+N-i5$IJ^BmKZ1$CR07~^Ver2iow`R|Tz ziGAn(Z;vlFSYCn?b0%a>9Fv)IeuFlWsbh1;XQrj(FWRV$MX`?lo3viSAtfy>Gk-!# zPEmGBVM*%PX6O%gT*p^w-M_Iy^MJE~%Db+*;K&&Zorn3vv)Tzh^*MOd5)cCs;FiPzWnfWCR#s*~p-_;XJt;k35Vj%# zyW&pUG*7z}dPC^NKu(tnjFZXnRMNvbTN{?>4)%YUBX9-6w#Yz4tEV+xm?=U)ZfppW{I8o^3$hXqExJfKzl# z0di^aKrTIO_e$(U+Uue#%wg?)I9(skZD7IEsIT|B4ShY`SX%Uwzm94DeCB+@Mv6F*tJnA*#gre4xCBtfV+w<&?*mtM_{`e%*iXE^)oEw%g~~rIVmZD7P&Dd z-3VxCaG=HXU(g0A>pModVC)7Nv^ZKZx3S9af*W9Pa^reh6GbJZl|>$;Ym!>cxzPH< zQdIm={KwEDplQvoi&em#p^lfF1da35v=^Xh-N2oAADY&QT(uB49?7bJNO8AP+0H8e z1YL{eGM>QH?4p*zwaI|SacE*oi)k&i4odRKC>I2JgRZUiRG?yOZ1 zMPX9I^=L)QtDp@tvXaeM06Ch50htGlXBBdRHWrKIDK(CJM55xhAZWrt)^GNgevPhN6OU*bwM1aN|?petrbR1ot@K_ z@<^-c0Q4lKDltk7P`smJ#IcGk$||o7(0q-PQW0hmzf>xt(1U@BcXW(6Td_r3#e+&Y zzJF9I@x4kh$5`c0+8D)P0!WS8Dr;k61sah-fD70Rv zqm$wfH{=$CJ0fZpsMnNr?`y<=q;tGuzjv3f4Gv&a{qbyC}jI!g^YC~LdM znEH0WN>bK$iIi6%)ZL!VdBxVvYHA#etku{oggPS@ZUMvd9JKDti`9y)yH#wfly|q9 z#)Sw%jJkmKArz%rS75T=)-`P?dWITqgD3k6XehW;P~FYz(72hd>Lie{^7|@&QIV!1 zgy8(6BBlKuc@(5xVajJcV#HaBxu;d~>!c+0jFBgzal9IIUj>bwDf$oH?PpQEd&P)3 zimjJbK5EgHH@txr4lf+F)(qQQ8bQg-|-2t256{Q?pzi2_gKc2K*EaDo)+}A390HPZ>XT`sbMfO0&x$Y*e znLG$V9$l8t$Xl4;dDU}IU`4HwlmnL)6@2`UF}}rRvEj7jWFcDlMl$!#`cyhM*(vM`*lJXqHi!t3B=7 zENF<<Ug~Yja`(w_@%D#O41O_l6VZT z;%AK%-%~1wSfwU?74M-jrgRjbZcgHUrF^JW@=8!X8yX{LC1}e>J2S0QY>8I+Iyf$} zhI$SPMlbOc!9>M`&Q;9AtfpPyFg2`^rfM~`zFDN)1=~C?Sy8FzY!Rm`W}8)fO|jXm zl4*dl))pfT8lZe;i;+J=jT2!f@T8Rb3{;YmV&u05YAXZ>D{PUzSS;LEXctyg1~jb) zFz4hKpm8xS%B7(e@du@RxK(cUq&V8c&%v%i(3v2KBQfeJq{aB*z z9{SQA3PjgyvHAAU5qroPQ(TKpu!mM71m}mkRM|r5Yl(mrOTJQGJ6MDPHs{Ulyh%;T)G zSDLnl;CjbdidT;R<7=Qa;{lYBUxTQCUAe z(v*jgO%1(;&=57$B-4nUiqMlPcNU>!HPm{XAY`f`1)&r*^oyNK9&gwkMre#0>z^eE z>1t>$LL=4C6?-T)+elG?kXDzw2x)e<9E013P$H+5M2bjwvfn-F7K0kWHt z+%r=C2q9j(*kG~u_)gF?>^-9_@-S%JZ8dp6#ATT|-zt0N8IB0+Le7VVg~o0Po%e|z zhoW$hZIN$3sK9aQ&ZmXk4mxjJ^$x_dhH{teMUQ+UkO*iL;2?6442_1+GR zrvTP^3yY~58XPG+N6Sh0i3~ivQy+_IGPE9w->^veG(ufep8LRavbLun=b;uk85*a- zk++>iT&I|)SmpP?asJ2$2R+jiqqLO3a2F_S4$s@ef^EQDWR?Aku++e59e@)nxR1bb zg*9K$V5;IhHOACuD*FPzsgb5~gc8)yReLCOn!%OYLuV0+SM9u}^LpmADfZ9{_R!Dv zP*kyDH^&}&*B$b)Utp=g=~HsLCE{ z{j?D~8KHR9?sbHEtD)MnjnH6wXuUmj)gEd;$FQ4X4;{CMYR%;~a}5UCLu>4zi}uhH z^SJeFH{KrFV-MZZLbId9`HJ^ktIK??Uu5;V^$xU9^otuWS5w*oY|-k(eG#Fa;MGYe z{{pQmw1(;;30r9NEKk=8XhRXFT@L;REe0Aka!l;7McU%3p;W|%yXcCxwhlnkT__BH zfEJ=A<8{}1@x$vb7aC`Ym5!^1!_c}xQ&+mFMwz`P(@P9)Gc2ds9St5++w#a8(pa9l5xja7ah8s`IFfk`xI zsp4H0BY(40JFcnQylh!!91oE|ELJMZtl|;Hyu>O=%ax=hG4j0S+9kf~EpgI#3{XnT)W|g|E zRJ@nRh=q!6Ic}M))at2ukl&Qb28xJ@xD zR?|hq^i%wlNK?BtINYeAB75kRJ>*ty#3tKA+Y!P9LfW4Zic>?8YmLy;_R!n*koz-6 z>~MQ%i#_z6JrueQ9jn#F9y(?Znbvdc`jt`62*j#pOYNZ#?IF(%M(l7SByLn}8;wrkOTcnyJlyKcHdV2v-meJRq#a~21u-F~L(?WQr+L|~;c8m`9@+@xpzWM} zDwMU)#K^}hw9^~r8D?Fp&DyOZRTE1T+d8XwPAOk!HF<2|9d`XQks@wtuD8NJ+16W4 zK3mm($cu7DAc~{qeF$|?rzJ8Je^P84tftn_@+9}`6>0h!p)|#Bqt(PUQHwEMd`|5y zq;2`Uwoq`c!R9>@8gDZ=*Ff70ts^w7UTm44KpVw5%7eCP$1AM?JE3Wwf_vj9Xnm1J zynh!8YRgbN&#Z$s1SZ%mZVt&qQy0e=PNq)~ong;FR zLmY>mM`)lLx{VO_RB*la8llw)VRr@h6++lQpNo`&_9@=mIu8sI@XQm0Ka3)zJuCPy z)_{aToFJHIf&_tUQ|%&w{COCil;T64C7eqaj>4x4gj02caBgvs+7MhhsUJp;M>EyW zU!e>O%p=u)l#Zi;TwW@KYnj0nW7gt zfM4QYgfsjv9ghJypW`~70M>_m2;mPSr~6dL&v}HHxCn7l z%8qD5`ZYBEFuFj#hnOHYAza)~y8auG`{^Ep?M#T|++-m4TYX*k0CIXWkUxyveqWvU z*YODv{lOWu)(sh14+3(6U?4|@=orchK8zgfh%at`Cm`ELF!?9w3x0?m??4POwtV;Om*x9e8B zbe@r$u?NW2-VfxcSNKau&h;>KuI8&c&&c-2ft>!N9{+|O57bu7n>xYB^*;;b1m|@A zVdMfn(0N7ob5R>pU(b<|E!=c$p!19z^w9OcLN;ln+c9!}o;rHzJR^Ix77`vAV1t$* zB&B$F6V)3B=w^>Yj%tlBo+IsbyMG6{ln%N*BM)sTkf|^oEt2l&5m2iY&awDB4mql` z9{(_ML6JJo$OXmd`oqX}{8TGr7d^gkN7{rlK)YGJjQeNiarjxh4T>4b_;=Qx>(00tl*;|PGTJ3*{$dX zt8@cK=GW@{I_8yK`#jY>ZliAXFmkb*z%y3p@r)eY!l<~u`2Pje{QLhx#@uUa z#d*W5Mgtgs)EnYJJ~QhPPD;D|o`F0nzvyNUBTuK_z%$+jic0hwO_as^z0?-mV+Jwz z^^}Zi5a!H_K(2rbC;u17^Pvvx*tDK*|1ffX^}%!g4V?J-9xb32gQuSS zVdRD0R_7VHxDL9`$iYy2@je`{^Zx_X2KIlD5XJs~6|ATCQBiFHcT|Gj5=Iv#`GBWd zzoF2$I}?FyWdm}5Bxst9F>VLb`v>3PCr5C3pf%VM$W%b=O^ntu$Jn1 z=YlXPXHRRxwOEgS7`Zyjz;pI1bX=+1F*3he*B?eMmh|{?AnR*^T-}X2UjgI~utd#h zi*B$D$O*RV`ir{0TgN>>{xGuLULE)8JR=|M59|8B#_^opKPO=B?~CcbFQ#z`ftkQ- z;qQxS_0iD3FQ(Ot>%T9i|Gt>!J)%CgLEeb|zL?hf;c*wy+)vE&iJ>!p{R49U{e3b0 zR~OOTV;s->(BBu+e_u@h&w=Jr(5_}K~cD0?>?|hk?$rd z2k*Kn*>~f`rOF{_V}Ey5{Cw|cY4lyy-4TPT?i1#VejoaFQ+sed;WNrZBG!w-QKzvMR zn3&=YqMbX4PpQZqM1Ti~Dkdr^&;!JICdxcOd`^{2%x?%Hx*>=vTF?+gcq0(knYc(1 zjX+#uVtpeJm#CVFHJ%_6JVAU#<(?qoyg=Mz;tKWl0&$y(U0xu*p*u`$GlNJrgQ%wM zW)Q=?LAZK@_>OGe=$7wkFT-_`eE>Hoh2aM}#PB1z`2ucICV;&Bko2rClKxC)KM<#x zDE0$!i_S1Hr7?(hjY0fIMU6oOGyze?#2pH30^&RqWlcc*PL)i|Zwey1DTsTtpecxO ze-PK1ct8>UAg(d7-XDZW)l95u1|p#u2q!9U1|qIGhQV|rJvzitpWIpj+$fWw0Uc*>CvyP6gYp;} z(iw(E654@iPUY=D#07!4$3#o&9R%Vw6T5;yw4ysqY-VJ0>c2EX(mG?RRU;!Cu9`e2^mGxf=(d9Eg-HlVWkKQh-*x&w}9wM)l95` z52S=}5Z$Rf97J3MhZe9VWJQ29ewuL~q*O8N{$i5U!CR;>i{X z!X*mCQ6>^djskIziR>s4{pk=BW1~U%MS~bfnb9D;VnCc_;z=^cfH=)WaSVtdbcTs3 zRuJv1AQGv_im!k!AgY+KQD7Gk=b0$$0%AB-GBLj^i0G~$l4(I#5aHcGTxVh=MRWsk zjfwT$K%`JL6KlGINazkCmCC!LYt!f^Lpt@21!PbK!x*~5FqQ`P0A$j3hH)hJ1dJye zLl*62$R@cLAcs;Ia_JDm1agZ5}blq%#bY$iEL@G8F+RARd*g z>VwJ^Q6Trjc_zxx@8UG71R+kR;J$!jS^%K%1Vmr&i|7)HNC00-%Nb@;HN!0G+7IwF zl{3tyn+$WPcYnZKs$iH$cNpf=zyW{-w4Grgi30(P$i}dk_A-=_JP5FaQW%!fA%TxVhD*xX#2eibw@?f__82BMQQ5PgQWXMlf;#4&)k$;R*w?PWMi z@>syTl)~^H9bz~~Zkd4hDU;y?I?nJRna2S>qCAF==?udsz)oGKYEP;fS&iWUGUJO`D#o{h>~q=+04*O*wJ1L6`@GqENYL_#izuc$m1MBD@r z_n5dsy(fUU&BU$=AikkHOl->ok(>vjnzrYG7?uyhH6O%xWXlKPQUKy86W2*D0CA9s z>;e!!&><$q7J~3A1aXrx3qg2I1aX#$pUFHC#AzmqCxW;|XPB5W2}HX|Abz8wNgx6y zgQ#NS4h2pIah{2?$sm5GN+#w{0TDd~#64Ot1w?oei0e!|pok(6*O*ve1VW^0Ce};^ zkuVj66O~T|5jPFQJtj=ldm4z_OzfHlq9)y8V%u~O$+hK6*80lWT8fF~6( zVAp3b9|)W!Hg^$UIFPVV3>8gDZSmmSfxKlRNe7lK6MZEb@Qmn80n5Z#al!%rP2z_l zfj_MbXyHtC6!C(q@xNI09Dtw42`Q&>oNW9bRadEarPyD5_rTeeqNiwjW7s2B`RN6@ zS(DO*bjn*TPS#2|CaRh5TqC-e-WZMl`NENDVL@tcPDbkZ)zr0IY}L>`UHkt$_@-%o zZdTUB33%l1KykU4D4D$R7;7zV$Pq2OHS5K!hg%S(7V_n4(M4n1IN=1Dutj{{)C~`; z*5@*xWD{{E4cscao7Ddb=D@&(VslYx+RTHa{3hh57Zzrw=eH!J5bz*2>QSiuM{Ytk z9e-AIH%jEboG!|ID>r2;9_r!jcc?YEupP}@^*sI`5JxkO|32iiE7cnRt z{o#RiJH_%09)aLmQ}id$wl*ZIyA;hooZo+-eGn90Qm+fO2Z$clxsv?w2!CFN4eNY6 z1dlAJAAUHI>&Z7$jpsiO=p6q@%?~GW{6TziKKxTp6~ZN)(R&E+vs--s;7f#=y8^^N z^@nHU0f(zP$In=LpkOZh8=b3#@Gee>&o!On$3dFwT(!>e!*d;wkbltrRunAilM${^ z_+cH+L;`YU>Oxu|5r4eEaqwJ2Ew_%1=|vCe6RDj*a~n#H-3}o`{&oEY2>|H%&j z&z<~dI{cSR{1*%AV*vad!W2jmWGbW>G7~Zj@-$>NWDX7wb$lSc zbkhs}O(;)CUV%K3+#&qT?2izBc=ssezaacz@D|9kkmn%$81Y)jGmz7ew;=qi@+?RZ zWGaLowjKcqffS);Qz6qJ(;>x>8IUzR8083*LTbUVHlz;36;c=SHTWx#tB`LX*C5r9 zZy~XeP7r=*qzp0-vH-FWG69kg;Rk2<*}6>-eh6m>5V zyw#93ka7q=xW`ZTl|f=49U#Gw5C}i2S_{IDH}8e)gKURXK==XccOmRZ`04FDNG>Wn z5`sT15;j6MK`P?#wHdMnvK8_iWHw|DWFBMzWFe#(GT_DE64D4#1Hv9ag4{;%E}iw3 zd`bo)$bZ=p1&M|%LW9?%j=w{GhU|gxV}qL^R!AtMBZQwLt^;v}oJ5xMAoC&ELxgRR zeUMimt0C!-Q;;_xry*w`XCaCB%R~MfYX19q^$$e`1Fet^$n-hLRtP_i8wRNd`3iB* z0@)R_>t#>NUi5)PFZxJ58#;kEL25v1LR=s+$-Yuzqag@(hOpyo2w`8@LZV5&(g>-K z6Mf<<4Q=oyxK_w=RAU72iEGC`vRMiLh|53daus;|N)g9dzXsc55T5aeAc5e&fbg!t zy9qxA&#vkzNED>ZLvIq9NA>41+?bJVKSpqDBEQVku9QfQ%de&633mUdd z)sihzA)|Gy0B(ZtEWw_v{)x#l;KO$OS0*bVD$Ht%YE{3B0o+ zUJ}YlY#}u%;n|44i8FX14PhgVZfma_Pfnhsn;~2V*LTZ9;b#%%coweT4hZMRGsv)K z{RPN&2y7Y%JN3v-K<>6Bw6_KJp~mPQ7FRu;m z9qQUlYFjH(*E);j-Ad|H!hIhDiH5LsPe?aC++D{Wz*xv|NN-3lNEZm($LYwM@?gk7 z$N)$mNMA@iBmvS7(jPJi@+5>eaBS*UbvHM5_9T#;>2%06h_S&;L6|q06v#-(C>;U%ygkAME$a9cqA$$Va3fTzR04ay8fe>UhWF3Sz zJl^=$LpDK}W4j6+S>Fud&GLB&Mp=#9sq5GxO4Pk#4~X3m?%@|9Q4rok_CxqI%cowh z1jqBf!aj%>%0b{^$Wh1<$g2>ZmOlf(hw#qLu9Ds6O@zONd;{U50=rX5io?7?;3~*3 zAzwi7Pk0?)$05cfVLS=_1O)%oxKjvlkMpFd66uN7(ufkC&lkb*kn#{-fqV_&;l2#H z1Y!HHfIQ?Z%yEUevPK1~5xxfD(fSFS> zXi$fs4#EXgsu5!5zZ2NK)yHc^jtNm?exi--C9`Cb=x}?fFK)Wm>>!1TPf#R-m2w&4 zXiEoaChp5050=bg2#H}*qsATKUz~92K(60DyQ$O8&tp6#F*qosBZ?FHP)aCFQ>hFo zeDYxD4ZEYwk9X)jd-fcMou1`I`kZst%pWbs8RewVF$afbOA@KE|60DrQ`0 z)@z1yedMimx-gZ3JEEqGDIVZsymF!cp$#v4e>^x7cAeBA5cW|C+nuKJj%Z&Jon>?V z)eWU@mhSj?&E99*JF-=)5{e%p!opzQgO10*d{ix*h|tI8daFj%8Mp8u^KrC347nH6 z0XEkf;A6b!VfK)vV{g?Ad>y%C;CSdX1F3Fpii(!JbbA9nrrbWr>oQfK7yg0N5*p-m zy3`XUD=C_XZad{Y1c%8#8lAY+f=<-OR&P@dxrU=L#`_LlP90NKqjBLxN81(Z3Ip5| zzKMEJVYr&Z(r{^vc$IzwNbc@5DgqN(Z>RAhhvx%*lJ|aWb93ahh%QASrw#Nwz{hwI z#M>K92S?UjauRmh`Vc;(_MKr@Ouaj!`?O~H81IjGtJ#HL*PUG1*kP`>w3G%$AzR~p z6YYHGj99buwO;>Zr+1T{O7ED?eI#!xjYQkD{-z8L7t?!@sEBSeB|_?G^tD=8sNNLZ zx!lM_|9KVl7X3;adn3O`inb5C&K{@SXtY`@z{hxrMc&t4zkKbPSL!%gbCqslMD^am zJZZlZOcQBz&hI}ks4bV2?*Xc~jwrq^jVG#}%I9f!uXu2(9|y5y*wK7?BSe=(76 zQ83s3UpDq}tu!k0xasm&aa!*@Hr3-u8q^(~IhnG%qs7KcM2^>(wXnIzx6e3Q96}W^ z5C_o_wliKrvZr&4W8UY^XE^Lu(ho53HQrjX`PA*^YxBFYYtq-2K0}N-tq$d*Q|Xb} z`S032JSW2bd}aUne0!u+EjN9p=rNB}@-NnfK5_M>rgyncK2nIjx_$OG)Bc<1`|}^1 zT5_cDS4Ur+qvBqYIqVx4_`=WEs*$m)fAt@)ISibd<9vXa_Vc$LU$-@y95J5sHj=Vy zJQpX0P-&dqn>@;0rDx(Wu9~AUqW(dp0lgnd4QK16&FL{d##>{e+fF{U<=&AGPy>A@ zoQ_{VrBEN^buvpT_6@mL{N4~4glW6ZBO41zQE+aLxO)8PS34ecdx4G*lRPLQUTUI; zn+Ef)WxRmq+O~!DKKZ1^1W62RrQH$NQ&hxD_~n~EkC%F2hxP9(eHu0ZcL`*4*b&!f z0uxVP?(QMBRu5FTkkMnNeLv>x{084ObHt3Mb_sBZ*_4ui+DxTs0H66VuZxU+8rN?{ z(|KRL<}hDPyI~Nv6$bTSaP*!1JzdIs4tE&5gqZq>nKZbqspY$KmUxIU(H(-q(WDb} zkMsT*2JSF;!rl4&h8<(}Xa>R9s;^OSKh)t5iU;_(q3#V~S3hAS-5j(b(ow_{RKf<4 zFz|wbX-uE-7i+h<#2MlIgU^%nQa=pNco>*r5Ey%+i`%Jfci13AHHaj!KMai5@w_Fv zeb#@+wYwO-;Gj@GM2(>S{n1S0{XMhYo6XN+-YM!R8K$BtfPtn$b1JK0r(g%@u{;Y;L!NCYqh;QG${-u z1RWCGL9;cnxCEtG zfASV7=JrPn&p@pIU&(K%)TS2SRrbS#v2~(BL#5V|(233^N?s<% zw23iHj29@?dEwl-&d%$`A}f_J7k+k6z&vmFOr~HcDHDryAgSHqd*C@WiOrw}}#NL~GU4^`<_?%b6ONU-)v$ zk%m}6XrFp2-%JI=q&7wZd$y``GfP^%+L3INaJA^jl-ureqO0cqVI^AWB5NPf9f#)AG)n#dZc4ZJ_j`ZciVu#c731ZLpH0=o8u&hT=+yC# zh^9SB*eYJ2;Nj?j*l7AXNeam?DYE}ZxO2^J!_9qRSyj`!zIr!e0F7UYzdR+(Oel|7CW%$Btpi)*jY(n~ zm$>B{>%NCHd`JoAA9X5{QR5n2=+$J*aJMeFc1MCsSJpp$zgIrtn9v|xp9-TWdIWlXE+vkTdbHRH zJHD`f%zml6w835JwGnW7`?~UdSfTWbFOIF>5pp!b898c~;3vs-Byv1YDKjOH8a%Jz z9lC;&q;B-oNVLSWo3@%ebXz;}K#h8mBW>$$v>tnp(>GZFqO{Q-hqBtzFseKAcFQw!8l6y0)`o=rF27D0q zL`0kK`(WJl!%d@jnvsIK7;p1h{Yv~67t6`tV1dEl{m*#s*Y3P4H*UvX|3jTz;XG&b zv{L=PbTvgP^)+79b)ot7{^ue#9#dzR>ft)|r&*)1V(!rX(b6tV_0&|!-$xmsP4#Va z3LCcFJ7tiXa|q|Wjy9%ZPc~lI<^QV3H~p_#H^CqrI~it!@k%f2?FnrT*StO-F&*H{ zk@P5CM^Yc--6lmh_n-gV`sxgAwnkvKt|9+4^v`aJOq0fx7;p88KGX8k?i-h)p$dBcIMc^_3&Wcr)0+_r3e3&deX_h%w$BCUnm%_YRvG;D|BaF19yD8hEX@ z(^1q|S*{n2uFpyg%%+_3}3ddp~zp?G-G-5aBI4o{pMpYuYDem^MeQ z`e(+>YqmWWcD$B5h6v-SUIy$YQeXzW(erpt3LRp+YV7Gf>F>3U=zmBzx8S!YS`Is3 zTQzP1^YZecwbmB=NxY*eC*+5mivcfR>e z?Jr)L{&A_p?gWiw1LGxe^)A03-1d*R|8N-GAtf6yP%3i(YyZH;%iMgRQT-nZ(9@$n2xgT2#c z8VCbiDvX~1^NX|*;A6bG?QETdkCOKP5GaW#Ry}tX(sh{o8n1MlS2J)!!j^r{>q`^f z%w;k)%aby~jJKU-`t|Ab()r)Jsg=j&DSpJCqPdBYahu+m?RlaJVz5%-+l38uG!K~@ zFHmb(w|7$N*iSpdpkt7Qlh!JtpYq_5jCaMYu3y?UH2K9IY8GKZA;H2KvgV^Xsz++n z7)c(twFbjycMx>vp@tvpJk%J~d6*^7Zge3Z3s3bD9yQe{xQhA(*w-KHDpZS;I8X4d zdAjBR>Uk~ub#>9HF}n9q!;keIYK-bV_$K9!0yIkX5_;Ki6<>1Mk98GlS_uoDFJU+M zbU6HBb!w8MY~6XNR*!WaYK-bU%y?9FS|Q3-&lw&y)R=R$yHM&Ozcf?ZEcekpSol1) zqa%2iP&?Wz;h@lWBDzp>$2HX`bZaSdTDLY;IpYnF^PP8&9qHD2idu0sCY#PqL`jeB zRnK5v|Q>lTX@%QbcZEuMmk8Smixx@zE?nb-YBAT6&@tmlc;wFn0l{_aIM(B#vsmIrEDwf1eVE8&#N)6vT#sq0*HRwpz$3&vjPi{JV#{UP}4=9-_!p*cdBO@77bi!C&B0bm~uWbHUT zKsQrqvE=Myyuxm7@0j>4zs$d}qy$w%T=CO)c&)T|}IIxz$YL9_MVd7&x1tnSbJJGz;~rQ*}1e zVF+h~OFxeqYLnlk-7`_o$6mOp2Koi!uQR2lI5m3Cl4geKFK#W}F!xCJZTIRvCy6t{ zv;||l*fF-=p63h8&Mia?yA>?`MRax+4hH_4v^y7Ri(eUpt^-d&S;gIRp>wG(v))jK4`ibsN%zxl)=u8jq5(vwLW%>I)tTI|w+n z&O?(PS=QX}QS&fS)OFl|$}9=+A@ix**_ zJDC%7X&wgNc#-ChCyOVn`}Ca;VZd_+i%xabW*_5?n{W8K-(O!{&r8dJJrC}V&R172 zyBr_mjhiDXe+=rk$$NprP8(M#b`@<}h|a~6Htd}YU*-F+hQsS&Z)S9e-fMdnz`+?W zBW>hb|B<9S`lNk3YA1C@*~kB06A#{+x`!4_6hG(x@M_Xyj!;eNlT>P#hRn@b0-(1 q=hL}YrA7yaFO$9xChucXJ$IdN->$tLY8SjJ&#A?Mp07*SXZ$~dioBKp delta 34250 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 Qmq@ol_8mGd-J11(00Ey@*Z=?k diff --git a/package.json b/package.json index 59be1f6..0c9c666 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@mantine/form": "^7.14.3", "@mantine/hooks": "^7.14.3", "@mantine/notifications": "^7.14.3", + "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^12.0.0", "@tabler/icons-react": "^3.21.0", "@tanstack/react-router": "^1.77.5", From 31bd9a15be00f5a3ebb2a5797358405c30af4201 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 23:40:42 +0900 Subject: [PATCH 12/20] =?UTF-8?q?base64=20=E3=81=A7=20publicKey=20?= =?UTF-8?q?=E3=82=92=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0011_busy_green_goblin.sql | 23 +++ drizzle/meta/0011_snapshot.json | 287 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/server/db/customType.ts | 10 + src/server/db/schema.ts | 6 +- 5 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 drizzle/0011_busy_green_goblin.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 src/server/db/customType.ts diff --git a/drizzle/0011_busy_green_goblin.sql b/drizzle/0011_busy_green_goblin.sql new file mode 100644 index 0000000..57dd6e5 --- /dev/null +++ b/drizzle/0011_busy_green_goblin.sql @@ -0,0 +1,23 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_passkeys` ( + `id` text PRIMARY KEY NOT NULL, + `public_key` text NOT NULL, + `user_id` text NOT NULL, + `webauthn_user_id` text NOT NULL, + `counter` integer NOT NULL, + `is_backed_up` integer NOT NULL, + `device_type` text NOT NULL, + `transports` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_passkeys`("id", "public_key", "user_id", "webauthn_user_id", "counter", "is_backed_up", "device_type", "transports", "created_at", "last_used_at") SELECT "id", "public_key", "user_id", "webauthn_user_id", "counter", "is_backed_up", "device_type", "transports", "created_at", "last_used_at" FROM `passkeys`;--> statement-breakpoint +DROP TABLE `passkeys`;--> statement-breakpoint +ALTER TABLE `__new_passkeys` RENAME TO `passkeys`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `passkeys_webauthn_user_id_unique` ON `passkeys` (`webauthn_user_id`);--> statement-breakpoint +CREATE INDEX `user_id_id_idx` ON `passkeys` (`user_id`,`id`);--> statement-breakpoint +CREATE INDEX `webauthn_user_id_id_idx` ON `passkeys` (`webauthn_user_id`,`id`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_id_webauthn_user_id_uk` ON `passkeys` (`user_id`,`webauthn_user_id`); \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..ae22732 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,287 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2121a8eb-37ec-4235-9d51-9417b691e5f9", + "prevId": "6d0cbc30-40ff-4f84-816a-15f8f60ba37b", + "tables": { + "fragments": { + "name": "fragments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scrap_id": { + "name": "scrap_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "fragments_scrap_id_scraps_id_fk": { + "name": "fragments_scrap_id_scraps_id_fk", + "tableFrom": "fragments", + "tableTo": "scraps", + "columnsFrom": [ + "scrap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fragments_author_id_users_id_fk": { + "name": "fragments_author_id_users_id_fk", + "tableFrom": "fragments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "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 + }, + "webauthn_user_id": { + "name": "webauthn_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_backed_up": { + "name": "is_backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_webauthn_user_id_unique": { + "name": "passkeys_webauthn_user_id_unique", + "columns": [ + "webauthn_user_id" + ], + "isUnique": true + }, + "user_id_id_idx": { + "name": "user_id_id_idx", + "columns": [ + "user_id", + "id" + ], + "isUnique": false + }, + "webauthn_user_id_id_idx": { + "name": "webauthn_user_id_id_idx", + "columns": [ + "webauthn_user_id", + "id" + ], + "isUnique": false + }, + "user_id_webauthn_user_id_uk": { + "name": "user_id_webauthn_user_id_uk", + "columns": [ + "user_id", + "webauthn_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scraps": { + "name": "scraps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "scraps_owner_id_users_id_fk": { + "name": "scraps_owner_id_users_id_fk", + "tableFrom": "scraps", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 12fb9c3..f81c27c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1734080866032, "tag": "0010_pretty_genesis", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1734100559451, + "tag": "0011_busy_green_goblin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/db/customType.ts b/src/server/db/customType.ts new file mode 100644 index 0000000..35b0bd9 --- /dev/null +++ b/src/server/db/customType.ts @@ -0,0 +1,10 @@ +import { customType } from 'drizzle-orm/sqlite-core' + +export const uint8ArrayAsBase64 = customType<{ + data: Uint8Array + driverData: string +}>({ + dataType: () => 'text', + toDriver: (value) => btoa(String.fromCharCode(...value)), + fromDriver: (value) => Uint8Array.from(Buffer.from(value, 'base64')), +}) diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 1b1b740..16f5365 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,5 +1,6 @@ import type { FragmentId } from '@/common/model/fragment' import type { UserId } from '@/common/model/user' +import { uint8ArrayAsBase64 } from '@/server/db/customType' import type { AuthenticatorTransportFuture, Base64URLString, @@ -7,7 +8,6 @@ import type { } from '@simplewebauthn/types' import { relations, sql } from 'drizzle-orm' import { index, int, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core' -import { blob } from 'drizzle-orm/sqlite-core/columns/blob' export const fragments = sqliteTable('fragments', { id: int().$type().primaryKey({ autoIncrement: true }), @@ -64,9 +64,7 @@ export const passkeys = sqliteTable( 'passkeys', { id: text().$type().primaryKey(), - publicKey: blob('public_key', { mode: 'buffer' }) - .$type() - .notNull(), + publicKey: uint8ArrayAsBase64('public_key').notNull(), userId: text('user_id') .$type() .notNull() From 42a43d03ca0d2bda6a9ab503100f5abc3e771bb4 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 23:40:51 +0900 Subject: [PATCH 13/20] @types/node --- bun.lockb | Bin 213673 -> 214066 bytes package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lockb b/bun.lockb index f1dd0ef3b62f6c699438ea021b3bf09ea7802556..47d7795ec8672443d0ff8121274b465635e6493e 100755 GIT binary patch delta 6173 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 Unz2DPqQ9(=HOeiGJ=TZ)2X}^qj{pDw delta 5906 zcmeI0e^gaf7RS#y&!;{F5fvXu$Y8qCj5quco`~|)%wLEOPHI@<4^$)tL`(sF0{J^q zz?*J$Q2c=krj*(WQHjW`tTDA_8f{X>eoQfDj+!Ypj`Q93y^FMF{+KnZe`c-TV)Nbm zbM`s+>~rpY_r7~*wO8c|uVP#8UP1k;<^G&mW5z98vY|2R`l|uyYkM``UHkaLx{~KR zwwmBS#3cCBwPa$D3h#U{`>dDtoA$qw$F~w3ew!_<+*L|Z90ig98gz$ux?GU0nbUBpyA#iVK-A*BVpkontfQBUq;SZg# z%XO62n;U(#~d$I2Y7X1&Aw5vuyF=3F0Ri6kNVA<3l4QFgTEXbxGS)o2cL z16sytwOWihWRno1buXvcJK5~{Y&KdaH+z-vM(I6n!W*G`FTNe)QHBQYW=synmglC7@`dlbmm7#i>k(E@#a3#$!(-(VEqbTJa-vd*a97hrySi1W9o-o#AJ>}DRJ>1! zvHFMwady?PPs`mEYw3xrY^n}<$;uE-XpU|n8fQf2;roS1(C!V4k(;!da);$K;&`+Q ztzf?0(ihhwmuTTj!|X&fOmp@f&=$^6VC+&)yY$tXb~`MSs<8Mvc{^%2gLOLy9x4g z#G3;2d^ke1g*)C|8LQOpRa^5oEfEo~$7CqSQ`}iCwGH?zaBfP)KQlBP%K7u$Jc}Fg zVdY&|;O2|moYfnAC6xOYn)puOa++0YTaQ@tcsj*M#1i`EX@!z5~i1Rvu@UyS)O+`IRP-r+27wgIC=8DwIF0oOsZ!huoZ%SNW(r{$Evy z!|&I8E4*86Buw0}jmP?e70coI02vuZoq4QA2 zWlEgKh0fzb8GJ#OOfEE&3(W-hifR}fVGuYU;A={q4={T^z-b1J9VBFhqhwo3rgmH?QkhQSdAflh!nl9Z0<9pKA`10WI4-eP@R}b-xo3#Ub*v%T<3_m~hha|qK z{SAyCj=W%1$m1~-6u+jhrLtve%FQS(U;T!u}*eci>*b6Xzn3)gbhnrGNeLHL- z{PER+4=IzBg%lj9UcrW+3siL-L(#$yoxCDFU=b$T(@iB&+aUFf$5dD8Vnhg~2dVBh zzM*%3dBZ$l`KYcf^i8Z+9qbKQ4Xii#B~krGkm~B0cNCx&b_{kLb_tC)q2I#(4r_w} z%&_b5d5?IHTOl^Vcz-19uV8B?n^WH&0(;rhw zdG7#xjm8G(XxKQ|SFkT(39!#$mtlA=7hgc*+{TP2%#$@H$R`+|c=5=+0p&?^I~T!) zU4iil9t-8XEP+QdBk4PUtFSNmK(K4>n0?MM@^}zF06*k4!N1?4?Vs?C6*CTa59}YX zyRh$J+~+pz5bOtuC;3}&?!dT;A7MN*u8a@J%$OL*Jb25=wDgT_;Vo(n<1q}~j`>=^ zlA+UK88G|;6YYZm+rf-2n*->H*bBz~96?Do)vbL;#MvSEhtD(XXr)a(TK$?$h00cT zn_X0Y^J`T)hGvXa9jI!l@{q@=W~oY%Ln$~<`OC2s!!m{HRw}D0T+F2m!~w>;nmM;$ z?;Cq#$SNh1?6&YoTeR3kR}0bM5FK0tsi)uwsPoZl}^!d7oYtF61 zBWzJ(ISpKi)-rl}mGY-~D^*{2Nv%;*1Uh3`{H3er0OP&em1h^PwDgZEa}~&Wb>y`Q zN7sjm_)jBDpt3?0M&GYeQL;7lS&d|48rJg z&?D3}Gpi|7WRR0L;-}j(mOfypPm7bBbRTh~@g`5qdgJY@#hIU>fN)zlwoVxD{1S7^ zYj62psxnFo6(=bE1+4D~Du4tSZwlXesM9~UHhTNH@_4SvbcXZX=S36+@>$|qS8PWD zM=h&#=jrRxa>YW86U-td z3@jOyp1fUKq@TwU@rcp|&u5)l$hiY+5Vs^`^(BA{X}=QvFu3(zNPzJxt|6{{pDLLZkoy diff --git a/package.json b/package.json index 0c9c666..8c06d8d 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@types/bcryptjs": "^2.4.6", "@types/bun": "^1.1.14", "@types/mdast": "^4.0.4", - "@types/node": "^22.7.7", + "@types/node": "^22.10.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "autoprefixer": "^10.4.20", From de35191ad8919f90e9e60072233b51b17d0fcf11 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 23:41:21 +0900 Subject: [PATCH 14/20] refactory: passkey repository --- src/server/repository/passkey/index.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/server/repository/passkey/index.ts b/src/server/repository/passkey/index.ts index fe4b5bc..9177537 100644 --- a/src/server/repository/passkey/index.ts +++ b/src/server/repository/passkey/index.ts @@ -17,15 +17,13 @@ export class PasskeyRepository implements IPasskeyRepository { where: (users, { eq }) => eq(users.id, userId), with: { passkeys: true }, }) + if (!user) return [] - return ( - user?.passkeys.map((p) => ({ - ...p, - user: { - id: user.id, - }, - })) ?? [] - ) + const passkeys: Passkey[] = user.passkeys.map((p) => ({ + ...p, + user, + })) + return passkeys } async find(credentialId: Passkey['id']) { From 27c5e4492562d1a9be27ac8e5ab43106f48f39c4 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Fri, 13 Dec 2024 23:56:18 +0900 Subject: [PATCH 15/20] =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/constant/passkey.ts | 3 + src/server/routes/auth/passkey.ts | 121 +++++++++++++------ src/server/service/passkey/authentication.ts | 17 +-- 3 files changed, 99 insertions(+), 42 deletions(-) diff --git a/src/server/constant/passkey.ts b/src/server/constant/passkey.ts index 3a45c9a..4a4021f 100644 --- a/src/server/constant/passkey.ts +++ b/src/server/constant/passkey.ts @@ -1 +1,4 @@ export const PASSKEY_REGISTRATION_SESSION_TTL = 60 * 5 // 5 minutes +export const PASSKEY_AUTHENTICATION_CHALLENGE_TTL = 60 * 5 // 5 minutes +export const PASSKEY_AUTHENTICATION_CHALLENGE_COOKIE_NAME = + 'authentication_challenge' diff --git a/src/server/routes/auth/passkey.ts b/src/server/routes/auth/passkey.ts index d4cc202..a9f167a 100644 --- a/src/server/routes/auth/passkey.ts +++ b/src/server/routes/auth/passkey.ts @@ -1,4 +1,10 @@ -import { PASSKEY_REGISTRATION_SESSION_TTL } from '@/server/constant/passkey' +import type { User } from '@/common/model/user' +import { + PASSKEY_AUTHENTICATION_CHALLENGE_COOKIE_NAME, + PASSKEY_AUTHENTICATION_CHALLENGE_TTL, + PASSKEY_REGISTRATION_SESSION_TTL, +} from '@/server/constant/passkey' +import { SESSION_COOKIE_NAME, SESSION_TTL } from '@/server/constant/session' import type { AppEnv } from '@/server/env' import { sessionAuthMiddleware } from '@/server/middleware/sessionAuth' import { PasskeyRepository } from '@/server/repository/passkey' @@ -20,6 +26,7 @@ import type { import { getCookie, setCookie } from 'hono/cookie' import { createMiddleware } from 'hono/factory' import { HTTPException } from 'hono/http-exception' +import { validator } from 'hono/validator' type AppEnvWithDeps = AppEnv & { Variables: { @@ -60,47 +67,93 @@ const passkeyAuth = honoFactory }) return c.json(options) }) - .post('/attestation', sessionAuthMiddleware, async (c) => { - const session = c.var.session - const body = await c.req.json() + .post( + '/attestation', + sessionAuthMiddleware, + // body が必要なことだけ明示する + validator('json', (value) => value), + async (c) => { + const session = c.var.session + const body = await c.req.json() - const { verified } = await c.var.passkeyRegistrationService.verify({ - userId: session.userId, - registrationResponse: body, - }) - if (!verified) { - throw new HTTPException(400) - } + let verified: boolean + try { + const verificationJSON = await c.var.passkeyRegistrationService.verify({ + userId: session.userId, + registrationResponse: body, + }) + verified = verificationJSON.verified + } catch (e) { + console.error(e) + throw new HTTPException(400) + } - return c.json(null, 201) - }) + if (!verified) { + throw new HTTPException(400) + } + + return c.json({ verified }, 201) + }, + ) .get('/assertion/options', async (c) => { const options = await c.var.passkeyAuthenticationService.generateOptions({}) - setCookie(c, 'ASSERTION_EXPECTED_CHALLENGE', options.challenge, { - httpOnly: true, - secure: import.meta.env.PROD, - sameSite: 'strict', - }) + setCookie( + c, + PASSKEY_AUTHENTICATION_CHALLENGE_COOKIE_NAME, + options.challenge, + { + httpOnly: true, + secure: import.meta.env.PROD, + sameSite: 'strict', + maxAge: PASSKEY_AUTHENTICATION_CHALLENGE_TTL, + }, + ) return c.json(options) }) - .post('/assertion', async (c) => { - const expectedChallenge = getCookie(c, 'ASSERTION_EXPECTED_CHALLENGE') - if (!expectedChallenge) { - throw new HTTPException(400) - } - const body = await c.req.json() - - const { verified } = await c.var.passkeyAuthenticationService.verify({ - authenticationResponse: body, - expectedChallenge, - }) - if (!verified) { - throw new HTTPException(400) - } + .post( + '/assertion', + // body が必要なことだけ明示する + validator('json', (value) => value), + async (c) => { + const expectedChallenge = getCookie( + c, + PASSKEY_AUTHENTICATION_CHALLENGE_COOKIE_NAME, + ) + if (!expectedChallenge) { + throw new HTTPException(400) + } + const body = await c.req.json() - return c.json(null, 201) - }) + let verified: boolean + let user: User + try { + const result = await c.var.passkeyAuthenticationService.verify({ + authenticationResponse: body, + expectedChallenge, + }) + verified = result.verified + user = result.user + } catch (e) { + console.error(e) + throw new HTTPException(400) + } + + if (!verified) { + throw new HTTPException(400) + } + + const session = await c.var.sessionRepository.createSession(user.id) + setCookie(c, SESSION_COOKIE_NAME, session.id, { + httpOnly: true, + sameSite: 'strict', + secure: import.meta.env.PROD, + maxAge: SESSION_TTL, + }) + + return c.json({ verified }, 200) + }, + ) export default passkeyAuth diff --git a/src/server/service/passkey/authentication.ts b/src/server/service/passkey/authentication.ts index 65003bf..094ab3e 100644 --- a/src/server/service/passkey/authentication.ts +++ b/src/server/service/passkey/authentication.ts @@ -1,7 +1,7 @@ +import type { User } from '@/common/model/user' import type { IPasskeyRepository } from '@/server/repository/passkey' import { origin, rpID } from '@/server/service/passkey/rp' import { - type VerifiedAuthenticationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server' @@ -16,13 +16,16 @@ export type VerifyInput = { expectedChallenge: string } +export type VerifyOutput = { + verified: boolean + user: User +} + export type IPasskeyAuthenticationService = { generateOptions( input: GenerateOptionsInput, ): Promise - verify( - input: VerifyInput, - ): Promise> + verify(input: VerifyInput): Promise } export class PasskeyAuthenticationService @@ -40,9 +43,7 @@ export class PasskeyAuthenticationService }) } - async verify( - input: VerifyInput, - ): Promise> { + async verify(input: VerifyInput): Promise { const passkey = await this.passkeyRepo.find(input.authenticationResponse.id) if (!passkey) { throw new Error('Passkey not found') @@ -70,6 +71,6 @@ export class PasskeyAuthenticationService await this.passkeyRepo.save(passkey) } - return { verified } + return { verified, user: passkey.user } } } From 85d6bc66648733c5ae079fbf991203f77afae8c9 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:30:43 +0900 Subject: [PATCH 16/20] =?UTF-8?q?last=20used=20at=20=E3=81=AE=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/repository/passkey/index.ts | 11 +++++++++++ src/server/service/passkey/authentication.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/src/server/repository/passkey/index.ts b/src/server/repository/passkey/index.ts index 9177537..a49c4c7 100644 --- a/src/server/repository/passkey/index.ts +++ b/src/server/repository/passkey/index.ts @@ -1,12 +1,14 @@ import type { UserId } from '@/common/model/user' import * as schema from '@/server/db/schema' import type { Passkey } from '@/server/model/passkey' +import { eq, sql } from 'drizzle-orm' import type { DrizzleD1Database } from 'drizzle-orm/d1' export interface IPasskeyRepository { findByUserId(userId: UserId): Promise find(credentialId: Passkey['id']): Promise save(passkey: Passkey): Promise + updateLastUsedAt(credentialId: Passkey['id']): Promise } export class PasskeyRepository implements IPasskeyRepository { @@ -65,4 +67,13 @@ export class PasskeyRepository implements IPasskeyRepository { }, }) } + + async updateLastUsedAt(credentialId: Passkey['id']): Promise { + await this.db + .update(schema.passkeys) + .set({ + lastUsedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(schema.passkeys.id, credentialId)) + } } diff --git a/src/server/service/passkey/authentication.ts b/src/server/service/passkey/authentication.ts index 094ab3e..2644599 100644 --- a/src/server/service/passkey/authentication.ts +++ b/src/server/service/passkey/authentication.ts @@ -69,6 +69,7 @@ export class PasskeyAuthenticationService const { newCounter } = verification.authenticationInfo! passkey.counter = newCounter await this.passkeyRepo.save(passkey) + await this.passkeyRepo.updateLastUsedAt(passkey.id) } return { verified, user: passkey.user } From 5a98db650b25e38328f8008b43f6320e4a388717 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:33:42 +0900 Subject: [PATCH 17/20] comments --- src/server/db/schema.ts | 1 + src/server/service/passkey/registration.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 16f5365..31667ee 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -84,6 +84,7 @@ export const passkeys = sqliteTable( AuthenticatorTransportFuture[] >(), createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`).notNull(), + // 最終ログイン日時 lastUsedAt: text('last_used_at'), }, (table) => ({ diff --git a/src/server/service/passkey/registration.ts b/src/server/service/passkey/registration.ts index 47f35e1..e70bf72 100644 --- a/src/server/service/passkey/registration.ts +++ b/src/server/service/passkey/registration.ts @@ -59,7 +59,6 @@ export class PasskeyRegistrationService implements IPasskeyRegistrationService { ...(passkey.transports && { transports: passkey.transports }), })), authenticatorSelection: { - // Defaults residentKey: 'required', userVerification: 'preferred', authenticatorAttachment: 'platform', From a6be1a824a1fdf5f501e758a70fde487a9e346d7 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:35:06 +0900 Subject: [PATCH 18/20] =?UTF-8?q?Number=20=E3=81=B8=E3=81=AE=E5=A4=89?= =?UTF-8?q?=E6=8F=9B=E4=B8=8D=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/service/passkey/authentication.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/service/passkey/authentication.ts b/src/server/service/passkey/authentication.ts index 2644599..cb64306 100644 --- a/src/server/service/passkey/authentication.ts +++ b/src/server/service/passkey/authentication.ts @@ -57,7 +57,7 @@ export class PasskeyAuthenticationService credential: { id: passkey.id, publicKey: passkey.publicKey, - counter: Number(passkey.counter), + counter: passkey.counter, transports: passkey.transports ?? undefined, }, requireUserVerification: false, From fc2b26da134d76e0e30ba3f47f428e5a91314910 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:45:03 +0900 Subject: [PATCH 19/20] =?UTF-8?q?onConflictDoUpdate=20=E3=81=AE=E3=82=AB?= =?UTF-8?q?=E3=83=A9=E3=83=A0=E3=82=92=E7=B5=9E=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/repository/passkey/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/server/repository/passkey/index.ts b/src/server/repository/passkey/index.ts index a49c4c7..468ff3e 100644 --- a/src/server/repository/passkey/index.ts +++ b/src/server/repository/passkey/index.ts @@ -57,9 +57,6 @@ export class PasskeyRepository implements IPasskeyRepository { .onConflictDoUpdate({ target: schema.passkeys.id, set: { - publicKey: passkey.publicKey, - userId: passkey.user.id, - webauthnUserId: passkey.webauthnUserId, counter: passkey.counter, isBackedUp: passkey.isBackedUp, deviceType: passkey.deviceType, From 087f2cb963f6a36e0b22b0890114315239df3918 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Sat, 14 Dec 2024 05:12:30 +0900 Subject: [PATCH 20/20] =?UTF-8?q?rpID,=20origin=20=E3=82=92=E5=8B=95?= =?UTF-8?q?=E7=9A=84=E3=81=AB=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/service/passkey/authentication.ts | 6 +++--- src/server/service/passkey/registration.ts | 6 +++--- src/server/service/passkey/rp.ts | 15 +++++++++++++-- src/server/tsconfig.json | 1 + vite-env.d.ts | 9 +++++++++ vite.config.ts | 3 +++ wrangler.toml | 4 ++++ 7 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 vite-env.d.ts diff --git a/src/server/service/passkey/authentication.ts b/src/server/service/passkey/authentication.ts index cb64306..5ba09d7 100644 --- a/src/server/service/passkey/authentication.ts +++ b/src/server/service/passkey/authentication.ts @@ -1,6 +1,6 @@ import type { User } from '@/common/model/user' import type { IPasskeyRepository } from '@/server/repository/passkey' -import { origin, rpID } from '@/server/service/passkey/rp' +import { origin, rpId } from '@/server/service/passkey/rp' import { generateAuthenticationOptions, verifyAuthenticationResponse, @@ -37,7 +37,7 @@ export class PasskeyAuthenticationService _input: GenerateOptionsInput, ): Promise { return await generateAuthenticationOptions({ - rpID, + rpID: rpId, userVerification: 'preferred', allowCredentials: [], }) @@ -53,7 +53,7 @@ export class PasskeyAuthenticationService response: input.authenticationResponse, expectedChallenge: input.expectedChallenge, expectedOrigin: origin, - expectedRPID: rpID, + expectedRPID: rpId, credential: { id: passkey.id, publicKey: passkey.publicKey, diff --git a/src/server/service/passkey/registration.ts b/src/server/service/passkey/registration.ts index e70bf72..d7e2afc 100644 --- a/src/server/service/passkey/registration.ts +++ b/src/server/service/passkey/registration.ts @@ -3,7 +3,7 @@ import type { Passkey } from '@/server/model/passkey' import type { IPasskeyRepository } from '@/server/repository/passkey' import type { IPasskeyRegistrationSessionRepository } from '@/server/repository/passkey/registrationSession' import type { IUserRepository } from '@/server/repository/user' -import { origin, rpID, rpName } from '@/server/service/passkey/rp' +import { origin, rpId, rpName } from '@/server/service/passkey/rp' import { type VerifiedRegistrationResponse, generateRegistrationOptions, @@ -50,7 +50,7 @@ export class PasskeyRegistrationService implements IPasskeyRegistrationService { const options: PublicKeyCredentialCreationOptionsJSON = await generateRegistrationOptions({ rpName, - rpID, + rpID: rpId, userName: user.id, // Don't prompt users for additional information about the authenticator attestationType: 'none', @@ -90,7 +90,7 @@ export class PasskeyRegistrationService implements IPasskeyRegistrationService { response: input.registrationResponse, expectedChallenge: registrationSession.challenge, expectedOrigin: origin, - expectedRPID: rpID, + expectedRPID: rpId, requireUserVerification: false, }) diff --git a/src/server/service/passkey/rp.ts b/src/server/service/passkey/rp.ts index 8c42329..d7875fb 100644 --- a/src/server/service/passkey/rp.ts +++ b/src/server/service/passkey/rp.ts @@ -1,3 +1,14 @@ export const rpName = 'Scrap' -export const rpID = 'localhost' -export const origin = `http://${rpID}:5173` +export const origin = + (import.meta.env.PROD && import.meta.env.CF_PAGES_URL) || + 'http://localhost:5173' +export const rpId = createRpId(new URL(origin).hostname) + +function createRpId(hostname: string): string { + if (hostname.endsWith('pages.dev')) { + // branch-name.project-name.pages.dev -> project-name.pages.dev + return hostname.split('.').slice(1).join('.') + } + // それ以外 = 開発環境ではそのまま + return hostname +} diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index ff904ce..a02f3cf 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -1,5 +1,6 @@ { "extends": ["../../tsconfig.base.json"], + "include": ["**/*", "../../vite-env.d.ts"], "compilerOptions": { "lib": ["ESNext"], "types": ["@cloudflare/workers-types", "vite/client", "node"], diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 0000000..627828b --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly CF_PAGES_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/vite.config.ts b/vite.config.ts index 0cf1698..20dd4b4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,7 +62,10 @@ export default defineConfig(({ mode, command }) => { } } + console.log(`CF_PAGES_URL: ${process.env.CF_PAGES_URL}`) + return { + envPrefix: ['VITE_', 'CF_'], ssr: { external: ['react', 'react-dom'], }, diff --git a/wrangler.toml b/wrangler.toml index bb31097..954ea32 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,6 +6,10 @@ compatibility_flags = [ "nodejs_compat" ] [vars] BUN_VERSION = '1.1.38' +[env.production.vars] +BUN_VERSION = '1.1.38' +CF_PAGES_URL = "https://scrap.smatsuo.dev" + [[kv_namespaces]] binding = "SESSION_KV" id = "c05d577e52254456931cef1e83fb0cf4"