From 1d4d8b888ec3debd43bd2ce365017a8fda6ec388 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 2 Sep 2025 15:54:48 +0200 Subject: [PATCH] Fix/auth fixes (#66) * Fix session refresh * Hardened passkey implementation * Go to logout if session cannot be retrieved --------- Co-authored-by: Karl Ludwig Weise --- project.inlang/messages/de.json | 6 +- src/lib/components/ui/passkey/state.svelte | 11 ++- src/lib/stores/auth.ts | 8 ++ src/lib/utils/passkey.ts | 79 +++++++++++-------- src/lib/utils/session.ts | 10 ++- src/routes/(pages)/dashboard/+layout.svelte | 2 +- src/routes/(pages)/login/login-form.svelte | 6 +- src/routes/(pages)/logout/+page.svelte | 6 ++ .../create-account-form.svelte | 7 +- 9 files changed, 94 insertions(+), 41 deletions(-) diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index fda6c22..9384e72 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -85,12 +85,12 @@ "passkey": { "add": { "initial": "Warten auf E-Mail Adresse", - "click": "Klicke/Tippe um einen Passkey hinzuzufügen", + "click": "Tippe um Passkey hinzuzufügen", "loading": "Lade Passkey-Challenge", "user": "Warte auf Passkey-Erstellung", "success": "Passkey hinzugefügt", - "error": "Passkey konnte nicht hinzugefügt werden", - "retry": "Erneut versuchen" + "error": "Passkey nicht hinzugefügt", + "retry": "Wdh." } }, "nav": { diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte index f00085d..4c0bd99 100644 --- a/src/lib/components/ui/passkey/state.svelte +++ b/src/lib/components/ui/passkey/state.svelte @@ -30,7 +30,11 @@ diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 2a8f4a1..676ad7f 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -37,6 +37,14 @@ function createAuthStore() { user: undefined, }); }, + isAuthenticated: () => { + let authState: AuthState; + const unsubscribe = store.subscribe((state) => { + authState = state; + }); + unsubscribe(); + return authState!.isAuthenticated; + }, }; } diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index f39d3b2..f838fcb 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -1,3 +1,5 @@ +import logger from "$lib/logger"; + export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { const bytes = new Uint8Array(buffer); let binary = ""; @@ -10,6 +12,16 @@ export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { return window.btoa(binary); }; +export const base64UrlToArrayBuffer = (base64url: string) => { + // Replace URL-safe characters and add padding + const base64 = base64url + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), "="); + + return base64ToArrayBuffer(base64); +}; + export function base64ToArrayBuffer(base64: string) { const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); @@ -35,10 +47,41 @@ export const fetchChallenge = async (email: string) => { challenge: data.challenge, }; } catch { + logger.error("Failed to fetch challenge", { email, status: resp.status }); return null; } }; +export const getCredentialOptions = ({ + id, + challenge, + email, +}: { + id: string; + challenge: string; + email: string; +}): { + publicKey: PublicKeyCredentialCreationOptions; +} => { + return { + publicKey: { + challenge: base64UrlToArrayBuffer(challenge), + rp: { + id, + name: "Open Reception", + }, + user: { + id: new Uint8Array(16), + name: email, + displayName: email, + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, // ES256 + ], + }, + }; +}; + export type GeneratePasskeyResponse = { response: AuthenticatorAttestationResponse; id: string; @@ -53,22 +96,8 @@ export const generatePasskey = async ({ challenge: string; email: string; }): Promise => { - 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 GeneratePasskeyResponse; + const options = getCredentialOptions({ id, challenge, email }); + return (await navigator.credentials.create(options)) as GeneratePasskeyResponse; }; export type GetCredentialResponse = PublicKeyCredential & { @@ -84,22 +113,8 @@ export const getCredential = async ({ challenge: string; email: string; }) => { - 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.get({ publicKey })) as GetCredentialResponse; + const options = getCredentialOptions({ id, challenge, email }); + return (await navigator.credentials.get(options)) as GetCredentialResponse; }; export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => { diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index e1b6213..55775b6 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -3,6 +3,8 @@ import { ROUTES } from "$lib/const/routes"; import { auth } from "$lib/stores/auth"; export const refreshSession = async () => { + if (!auth.isAuthenticated()) return; + auth.setRefreshing(true); try { @@ -15,7 +17,6 @@ export const refreshSession = async () => { }); if (!response.ok) { - // Token is invalid, clear and redirect to login if (response.status === 401) { auth.setRefreshing(false); goto(ROUTES.LOGOUT); @@ -39,6 +40,13 @@ export const refreshUserData = async () => { credentials: "same-origin", }); + if (!response.ok) { + if (response.status === 401) { + goto(ROUTES.LOGOUT); + return; + } + } + const data = await response.json(); auth.setUser(data.user); } catch (error) { diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index c1626f9..8872502 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -11,7 +11,7 @@ const unsubscribe = () => { if (!intervalId) { refreshSession(); - intervalId = setInterval(refreshSession, 10 * 1000); // 10 minutes + intervalId = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes } }; diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 7ce74b8..90f1471 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -18,6 +18,7 @@ import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey"; import { Label } from "$lib/components/ui/label"; import { auth } from "$lib/stores/auth"; + import logger from "$lib/logger"; let { data, @@ -84,16 +85,19 @@ if (!challenge) { $passkeyLoading = "error"; + logger.error("Failed to fetch challenge", { email: $formData.email }); } else { $passkeyLoading = "user"; const credentialResp = await getCredential({ ...challenge, email: $formData.email }).catch( - () => { + (error) => { $passkeyLoading = "error"; + logger.error("Failed to get credential", { ...challenge, error }); }, ); if (!credentialResp) { $passkeyLoading = "error"; + logger.error("Credential response is falsy"); return; } diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte index f5ce902..59f7b80 100644 --- a/src/routes/(pages)/logout/+page.svelte +++ b/src/routes/(pages)/logout/+page.svelte @@ -6,9 +6,15 @@ import { PageWithClaim } from "$lib/components/ui/page"; import { Skeleton } from "$lib/components/ui/skeleton"; import { ROUTES } from "$lib/const/routes.js"; + import { auth } from "$lib/stores/auth.js"; import Check from "@lucide/svelte/icons/check"; + import { onMount } from "svelte"; export let data; + + onMount(() => { + auth.reset(); + }); 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 dc9713a..b9cf01e 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 @@ -19,6 +19,7 @@ import { Label } from "$lib/components/ui/label"; import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte"; import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey"; + import logger from "$lib/logger"; let { data, @@ -90,17 +91,20 @@ const challenge = await fetchChallenge($formData.email); if (!challenge) { + logger.error("Failed to fetch challenge", { email: $formData.email }); $passkeyLoading = "error"; } else { $passkeyLoading = "user"; const passkeyResp = await generatePasskey({ ...challenge, email: $formData.email }).catch( - () => { + (error) => { $passkeyLoading = "error"; + logger.error("Failed to generate passkey", { ...challenge, error }); }, ); if (!passkeyResp) { $passkeyLoading = "error"; + logger.error("Passkey response is falsy"); return; } @@ -108,6 +112,7 @@ const publicKey = passkeyResp.response.getPublicKey(); if (!publicKey) { $passkeyLoading = "error"; + logger.error("Failed to get public key", { email: $formData.email }); return; }