From 4c88574bbc334680b20e4a04c2bee7c5d8b9b4fe Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 22 Aug 2025 15:55:53 +0200 Subject: [PATCH 1/2] Adding setup with passkey -wip --- project.inlang/messages/de.json | 27 ++- project.inlang/messages/en.json | 33 ++- src/hooks.server.ts | 6 +- src/lib/components/ui/input/input.svelte | 4 +- src/lib/components/ui/passkey/index.ts | 5 + src/lib/components/ui/passkey/state.svelte | 45 ++++ src/lib/const/routes.ts | 6 +- src/lib/server/auth/webauthn-service.ts | 2 +- .../(pages)/confirm/[token]/+page.server.ts | 1 - src/routes/(pages)/dashboard/+page.svelte | 2 + src/routes/(pages)/login/+page.server.ts | 39 ++++ src/routes/(pages)/login/+page.svelte | 54 ++++- src/routes/(pages)/login/login-form.svelte | 86 +++++++ src/routes/(pages)/login/schema.ts | 9 + src/routes/(pages)/logout/+page.server.ts | 18 ++ src/routes/(pages)/logout/+page.svelte | 41 ++++ .../create-admin-account/+page.server.ts | 29 ++- .../create-account-form.svelte | 209 +++++++++++++++--- .../setup/create-admin-account/schema.ts | 27 ++- .../{authHandle.ts => apiAuthHandle.ts} | 26 +-- src/server-hooks/authGuard.ts | 42 ++++ src/server-hooks/utils/accessToken.ts | 24 ++ 22 files changed, 655 insertions(+), 80 deletions(-) create mode 100644 src/lib/components/ui/passkey/index.ts create mode 100644 src/lib/components/ui/passkey/state.svelte create mode 100644 src/routes/(pages)/dashboard/+page.svelte create mode 100644 src/routes/(pages)/login/+page.server.ts create mode 100644 src/routes/(pages)/login/login-form.svelte create mode 100644 src/routes/(pages)/login/schema.ts create mode 100644 src/routes/(pages)/logout/+page.server.ts create mode 100644 src/routes/(pages)/logout/+page.svelte rename src/server-hooks/{authHandle.ts => apiAuthHandle.ts} (84%) create mode 100644 src/server-hooks/authGuard.ts create mode 100644 src/server-hooks/utils/accessToken.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 11d17aa..798ae78 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -42,13 +42,28 @@ "passphraseRequirements": "Mindestens 30 Zeichen.", "errors": { "email": "Ungültiges Format", - "passphrase": "Muss mindestens 30 Zeichen lang sein" - } + "passphrase": "Muss mindestens 30 Zeichen lang sein", + "noPassAtAll": "Either passphrase or passkey is required", + "bothPassSet": "Nur Passphrase oder Passkey darf gesetzt sein" + }, + "passkey": "Passkey" }, "login": { "or": "Oder", "usePasskey": "Passkey verwenden", - "usePassphrase": "Passphrase verwenden" + "usePassphrase": "Passphrase verwenden", + "title": "Anmelden", + "description_passphrase": "Gib Deine E-Mail-Adresse und Deinen Passphrase ein, um Dich anzumelden.", + "action": "Anmelden", + "error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen", + "passkey": { + "initial": "Warte auf Absenden des Formulars", + "success": "Passkey bereit", + "error": "Passkey konnte nicht abgerufen werden", + "user": "Warte während Passkey erstellt wird", + "loading": "Teste Passkey", + "retry": "Erneut versuchen" + } }, "confirm": { "success": { @@ -68,5 +83,11 @@ "action": "E-Mail erneut senden", "success": "E-Mail gesendet" } + }, + "logout": { + "title": "Erfolgreich abgemeldet", + "description": "Du wurdest abgemeldet.", + "success": "Erfolgreich abgemeldet", + "action": "Zur Anmeldung" } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index fc907d7..5f3adcf 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -43,14 +43,35 @@ "passphraseRequirements": "At least 30 characters.", "errors": { "email": "Invalid format", - "passphrase": "Must be at least 30 characters long" - } + "passphrase": "Must be at least 30 characters long", + "noPassAtAll": "Either passphrase or passkey is required", + "bothPassSet": "Only one of passphrase or passkey should be provided" + }, + "noPassAtAll": "Either passphrase or passkey is required", + "passkey": "Passkey" }, "login": { "or": "Or", "usePasskey": "use passkey", "usePassphrase": "use passphrase", - "orUse": "Or {button}" + "orUse": "Or {button}", + "title": "Login", + "description_passphrase": "Enter your email and passphrase below to access your account.", + "action": "Login", + "error": "Login failed. Please retry", + "passphrase": { + "initial": "Requesting passkey", + "success": "Passkey saved", + "error": "Passkey could not be retrieved" + }, + "passkey": { + "initial": "Waiting for form to be sent", + "success": "Added Passkey successfully", + "error": "Passkey could not be added", + "user": "Waiting for passkey to be created", + "loading": "Completing challenge", + "retry": "Retry" + } }, "confirm": { "success": { @@ -70,5 +91,11 @@ "action": "Resend E-Mail", "success": "E-Mail sent" } + }, + "logout": { + "title": "Logout complete", + "description": "You are logged-out.", + "success": "Logout successful", + "action": "Go to Login" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e98e909..8d234ca 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,7 +5,8 @@ import { corsHandle } from "./server-hooks/corsHandle"; import { loggingHandle } from "./server-hooks/loggingHandle"; import { rateLimitHandle } from "./server-hooks/rateLimitHandle"; import { secHeaderHandle } from "./server-hooks/secHeaderHandle"; -import { authHandle } from "./server-hooks/authHandle"; +import { apiAuthHandle } from "./server-hooks/apiAuthHandle"; +import { authGuard } from "./server-hooks/authGuard"; import { i18nHandle } from "./server-hooks/i18nHandle"; import { building } from "$app/environment"; @@ -40,5 +41,6 @@ export const handle = sequence( rateLimitHandle, corsHandle, secHeaderHandle, - authHandle + apiAuthHandle, + authGuard ); diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte index 19c6dae..29c91d8 100644 --- a/src/lib/components/ui/input/input.svelte +++ b/src/lib/components/ui/input/input.svelte @@ -24,7 +24,7 @@ bind:this={ref} data-slot="input" class={cn( - "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className @@ -39,7 +39,7 @@ bind:this={ref} data-slot="input" class={cn( - "border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className diff --git a/src/lib/components/ui/passkey/index.ts b/src/lib/components/ui/passkey/index.ts new file mode 100644 index 0000000..d53af12 --- /dev/null +++ b/src/lib/components/ui/passkey/index.ts @@ -0,0 +1,5 @@ +import State from "./state.svelte"; + +const Passkey = { State }; + +export { Passkey }; diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte new file mode 100644 index 0000000..3191b33 --- /dev/null +++ b/src/lib/components/ui/passkey/state.svelte @@ -0,0 +1,45 @@ + + +
+ {#if state === "initial"} + + {m["login.passkey.initial"]()} + {:else if state === "user"} + + {m["login.passkey.user"]()} + {:else if state === "loading"} + + {m["login.passkey.loading"]()} + {:else if state === "success"} + + {m["login.passkey.success"]()} + {:else if state === "error"} + + {m["login.passkey.error"]()} + {/if} +
diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index 1c6551a..2bbbffe 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -5,5 +5,9 @@ export const ROUTES = { CHECK_EMAIL: "/setup/check-email" }, RESEND_CONFIRMATION: "/confirm/resend", - LOGIN: "/login" + LOGIN: "/login", + LOGOUT: "/logout", + DASHBOARD: { + MAIN: "/dashboard" + } }; diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index ef0269d..571bbb0 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -189,7 +189,7 @@ export class WebAuthnService { * This should be stored in the session and used for verification */ static generateChallenge(): string { - return randomBytes(32).toString("base64url"); + return randomBytes(32).toString("base64"); } /** diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index f14eb9e..d1718f9 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -16,7 +16,6 @@ export const load: PageServerLoad = async (event) => { const success = resp.status < 400; try { const body = await resp.json(); - // TODO: Check if this is the first account const isSetup = body.isSetup ?? false; return { success, isSetup }; } catch (error) { diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte new file mode 100644 index 0000000..f29a407 --- /dev/null +++ b/src/routes/(pages)/dashboard/+page.svelte @@ -0,0 +1,2 @@ +Hello from Dashboard
+Logout diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts new file mode 100644 index 0000000..788ca12 --- /dev/null +++ b/src/routes/(pages)/login/+page.server.ts @@ -0,0 +1,39 @@ +import { fail } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)) + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + return fail(400, { + form + }); + } + + const resp = await event.fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email: form.data.email, passphrase: form.data.passphrase }) + }); + + if (resp.status < 400) { + return { form }; + } else { + return fail(400, { + form + }); + } + } +}; diff --git a/src/routes/(pages)/login/+page.svelte b/src/routes/(pages)/login/+page.svelte index 76f6852..24de273 100644 --- a/src/routes/(pages)/login/+page.svelte +++ b/src/routes/(pages)/login/+page.svelte @@ -1 +1,53 @@ -Hello from login + + + + {m["login.title"]()} - OpenReception + + + + + + + {m["login.title"]()} + + + {m["login.description_passphrase"]()} + + + + + + + + {m["login.action"]()} + + + + diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte new file mode 100644 index 0000000..64a9214 --- /dev/null +++ b/src/routes/(pages)/login/login-form.svelte @@ -0,0 +1,86 @@ + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + {/snippet} + + + + {m["login.or"]()} + {#if $isUsingPasskey} + + {:else} + + {/if}. + + + diff --git a/src/routes/(pages)/login/schema.ts b/src/routes/(pages)/login/schema.ts new file mode 100644 index 0000000..53f6013 --- /dev/null +++ b/src/routes/(pages)/login/schema.ts @@ -0,0 +1,9 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + email: z.string().email(m["form.errors.email"]()), + passphrase: z.string().min(30, m["form.errors.passphrase"]()) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts new file mode 100644 index 0000000..86ef326 --- /dev/null +++ b/src/routes/(pages)/logout/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const success: Promise = event + .fetch("/api/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json" + } + }) + .then(async (resp) => { + // TODO: Why is this failing? + console.log("resp", resp, await resp.json()); + return resp.status < 400; + }); + + return { streaming: { success } }; +}; diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte new file mode 100644 index 0000000..27a0cdc --- /dev/null +++ b/src/routes/(pages)/logout/+page.svelte @@ -0,0 +1,41 @@ + + + + {m["logout.title"]()} - OpenReception + + + + + + {#await data.streaming.success} + + {:then} + + {/await} + + + {#await data.streaming.success} + + {:then} + + {/await} + + + diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 3cce61a..0cf17cb 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -25,19 +25,30 @@ export const actions: Actions = { } // Create admin account - const admin = await UserService.createUser({ - name: "Admin", - email: form.data.email, - passphrase: form.data.passphrase, - language: form.data.language - }, event.url); - const hasPasskey = false; + const admin = await UserService.createUser( + { + name: "Admin", + email: form.data.email, + passphrase: form.data.passphrase, // Will be undefined if passkey is used + language: form.data.language + }, + event.url + ); + + if (form.data.passkey) { + await UserService.addPasskey(admin.id, { + id: form.data.passkey.id, + publicKey: form.data.passkey.publicKey, + counter: form.data.passkey.counter || 0, + deviceName: form.data.passkey.deviceName || "Unknown Device" + }); + } log.debug("Admin account created successfully", { adminId: admin.id, email: admin.email, - authMethod: hasPasskey ? "passkey" : "passphrase" - // passkeyId: hasPasskey ? body.passkey.id : undefined + authMethod: form.data.passkey ? "passkey" : "passphrase", + passkeyId: form.data.passkey ? form.data.passkey.id : undefined }); return { form }; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index ef68386..19e5f3f 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -1,18 +1,23 @@ @@ -63,7 +206,7 @@ - + +
+ + + + {m["login.or"]()} + . + +
diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts index 0f7271d..187786f 100644 --- a/src/routes/(pages)/setup/create-admin-account/schema.ts +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -1,10 +1,27 @@ import { m } from "$i18n/messages"; import { z } from "zod"; -export const formSchema = z.object({ - email: z.string().email(m["form.errors.email"]()), - passphrase: z.string().min(30, m["form.errors.passphrase"]()), - language: z.enum(["de", "en"]) -}); +export const formSchema = z + .object({ + email: z.string().email(m["form.errors.email"]()), + passphrase: z.string().min(30, m["form.errors.passphrase"]()).optional(), + passkey: z + .object({ + id: z.string().min(10), + publicKey: z.string().min(10), + counter: z.number().min(0), + deviceName: z.string().min(1).optional() + }) + .optional(), + language: z.enum(["de", "en"]) + }) + .refine((data) => !!data.passphrase || !!data.passkey, { + message: m["form.errors.noPassAtAll"](), + path: ["passphrase"] + }) + .refine((data) => !(data.passphrase && data.passkey), { + message: m["form.errors.bothPassSet"](), + path: ["passphrase"] + }); export type FormSchema = typeof formSchema; diff --git a/src/server-hooks/authHandle.ts b/src/server-hooks/apiAuthHandle.ts similarity index 84% rename from src/server-hooks/authHandle.ts rename to src/server-hooks/apiAuthHandle.ts index b1f398c..23e4a09 100644 --- a/src/server-hooks/authHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -2,12 +2,13 @@ import type { Handle } from "@sveltejs/kit"; import { SessionService } from "$lib/server/auth/session-service"; import { UniversalLogger } from "$lib/logger"; import { AuthorizationService } from "$lib/server/auth/authorization-service"; +import { getAccessToken } from "./utils/accessToken"; const logger = new UniversalLogger().setContext("AuthHandle"); -const ACCESS_TOKEN_COOKIE_NAME = "access_token"; const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin", "/api/tenants", "/api/auth/register"]; const PUBLIC_PATHS = [ + "/", "/api/auth/challenge", "/api/auth/login", "/api/auth/register", @@ -33,8 +34,8 @@ const PROTECTED_AUTH_PATHS = [ "/api/auth/invite" ]; -export const authHandle: Handle = async ({ event, resolve }) => { - const { url, request } = event; +export const apiAuthHandle: Handle = async ({ event, resolve }) => { + const { url } = event; const path = url.pathname; if (!path.startsWith("/api")) { @@ -63,22 +64,7 @@ export const authHandle: Handle = async ({ event, resolve }) => { ); } - let accessToken: string | null = null; - - // Get access token from cookie - const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME); - if (accessTokenCookie) { - accessToken = accessTokenCookie; - } - - // Fallback: check Authorization header - if (!accessToken) { - const authHeader = request.headers.get("authorization"); - if (authHeader?.startsWith("Bearer ")) { - accessToken = authHeader.substring(7); - } - } - + const accessToken: string | null = getAccessToken(event); if (!accessToken) { logger.warn(`Authentication required for ${path}`); return new Response(JSON.stringify({ error: "Authentication required" }), { @@ -104,8 +90,6 @@ export const authHandle: Handle = async ({ event, resolve }) => { sessionId: sessionData.sessionId }; - logger.debug(`Added user information for ${sessionData.user.id}`); - if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) { return new Response(JSON.stringify({ error: "Authentication failed" }), { status: 403, diff --git a/src/server-hooks/authGuard.ts b/src/server-hooks/authGuard.ts new file mode 100644 index 0000000..37bf404 --- /dev/null +++ b/src/server-hooks/authGuard.ts @@ -0,0 +1,42 @@ +import { redirect, type Handle } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import { AuthorizationService } from "$lib/server/auth/authorization-service"; +import { getAccessToken } from "./utils/accessToken"; +import { ROUTES } from "$lib/const/routes"; + +export const authGuard: Handle = async ({ event, resolve }) => { + const { url } = event; + const path = url.pathname; + + // Do not handle api paths + if (path.startsWith("/api")) { + return resolve(event); + } + + // Only guard protected routes + const isDashboardRoute = path.startsWith(ROUTES.DASHBOARD.MAIN); + if (isDashboardRoute) { + // Check for accessToken + const accessToken: string | null = getAccessToken(event); + if (!accessToken) redirect(302, ROUTES.LOGIN); + + // Verify access token with database session check + const sessionData = await SessionService.validateTokenWithDB(accessToken); + if (!sessionData) redirect(302, ROUTES.LOGIN); + + switch (true) { + case isDashboardRoute && + AuthorizationService.hasAnyRole(sessionData.user, [ + "GLOBAL_ADMIN", + "TENANT_ADMIN", + "STAFF" + ]): + return resolve(event); + default: + // Access not granted + redirect(302, ROUTES.LOGIN); + } + } + + return resolve(event); +}; diff --git a/src/server-hooks/utils/accessToken.ts b/src/server-hooks/utils/accessToken.ts new file mode 100644 index 0000000..0fcb0d8 --- /dev/null +++ b/src/server-hooks/utils/accessToken.ts @@ -0,0 +1,24 @@ +import type { RequestEvent } from "@sveltejs/kit"; + +const ACCESS_TOKEN_COOKIE_NAME = "access_token"; +export const getAccessToken = ( + event: RequestEvent>, string | null> +): string | null => { + let accessToken: string | null = null; + + // Get access token from cookie + const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME); + if (accessTokenCookie) { + accessToken = accessTokenCookie; + } + + // Fallback: check Authorization header + if (!accessToken) { + const authHeader = event.request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + accessToken = authHeader.substring(7); + } + } + + return accessToken; +}; From f76e9c94180dec1307d273b1c839fd30519d1c59 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 22 Aug 2025 16:05:45 +0200 Subject: [PATCH 2/2] Cleanup --- src/lib/utils/passkey.ts | 69 ++++++++++++++++ .../create-account-form.svelte | 78 +------------------ 2 files changed, 71 insertions(+), 76 deletions(-) create mode 100644 src/lib/utils/passkey.ts diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts new file mode 100644 index 0000000..4a45267 --- /dev/null +++ b/src/lib/utils/passkey.ts @@ -0,0 +1,69 @@ +export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ""; + + // Simple loop, no fancy operations + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return window.btoa(binary); +}; + +function base64ToArrayBuffer(base64: string) { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +export const fetchChallenge = async (email: string) => { + const resp = await fetch("/api/auth/challenge", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email }) + }); + + try { + const data = await resp.json(); + return { + id: data.rpId, + challenge: data.challenge + }; + } catch { + return null; + } +}; + +export const generatePasskey = async ({ + id, + challenge, + email +}: { + id: string; + challenge: string; + email: string; +}): Promise<{ response: AuthenticatorAttestationResponse } | null> => { + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: base64ToArrayBuffer(challenge), + rp: { + id, + name: "Open Reception" + }, + user: { + id: new Uint8Array(16), + name: email, + displayName: email + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" } // ES256 + ] + }; + return (await navigator.credentials.create({ publicKey })) as { + response: AuthenticatorAttestationResponse; + } | null; +}; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index 19e5f3f..39d2fdb 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -17,7 +17,7 @@ import { Text } from "$lib/components/ui/typography"; import { Label } from "$lib/components/ui/label"; import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte"; - import { email, unknown } from "zod/v4"; + import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey"; let { data, @@ -43,7 +43,7 @@ if ($isUsingPasskey) { $passkeyLoading = "user"; - fetchChallenge().then((challenge) => { + fetchChallenge($formData.email).then((challenge) => { if (!challenge) { $passkeyLoading = "error"; } else { @@ -100,80 +100,6 @@ } }); - function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - - // Simple loop, no fancy operations - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - - return window.btoa(binary); - } - - function base64ToArrayBuffer(base64: string) { - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; - } - - const fetchChallenge = async () => { - const resp = await fetch("/api/auth/challenge", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ email: $formData.email }) - }); - - try { - const data = await resp.json(); - return { - id: data.rpId, - challenge: data.challenge - }; - } catch (error) { - return null; - } - }; - - const generatePasskey = async ({ - id, - challenge, - email - }: { - id: string; - challenge: string; - email: string; - }): Promise<{ response: AuthenticatorAttestationResponse } | null> => { - try { - const publicKey: PublicKeyCredentialCreationOptions = { - challenge: base64ToArrayBuffer(challenge), - rp: { - id, - name: "Open Reception" - }, - user: { - id: new Uint8Array(16), - name: email, - displayName: email - }, - pubKeyCredParams: [ - { alg: -7, type: "public-key" } // ES256 - ] - }; - return (await navigator.credentials.create({ publicKey })) as { - response: AuthenticatorAttestationResponse; - } | null; - } catch (error) { - throw error; - } - }; - $effect(() => { if ($isUsingPasskey) { formData.update((data) => ({