Fix/auth fixes (#66)

* Fix session refresh

* Hardened passkey implementation

* Go to logout if session cannot be retrieved

---------

Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
Karl Ludwig Weise
2025-09-02 15:54:48 +02:00
committed by GitHub
co-authored by Karl Ludwig Weise
parent bf610cac2b
commit 1d4d8b888e
9 changed files with 94 additions and 41 deletions
+3 -3
View File
@@ -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": {
+9 -2
View File
@@ -30,7 +30,11 @@
<Button
variant="outline"
onclick={onClick}
class={cn("flex w-full justify-start", className)}
class={cn(
"flex w-full justify-start",
state === "error" ? "dark:border-destructive border-destructive text-destructive" : "",
className,
)}
disabled={disabledStates.includes(state)}
>
{#if state === "initial"}
@@ -50,6 +54,9 @@
{m["passkey.add.success"]()}
{:else if state === "error"}
<Error class="size-4" />
{m["passkey.add.error"]()}
<div class="flex w-full flex-wrap justify-between overflow-hidden">
<div>{m["passkey.add.error"]()}</div>
<div>{m["passkey.add.retry"]()}</div>
</div>
{/if}
</Button>
+8
View File
@@ -37,6 +37,14 @@ function createAuthStore() {
user: undefined,
});
},
isAuthenticated: () => {
let authState: AuthState;
const unsubscribe = store.subscribe((state) => {
authState = state;
});
unsubscribe();
return authState!.isAuthenticated;
},
};
}
+47 -32
View File
@@ -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<GeneratePasskeyResponse> => {
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) => {
+9 -1
View File
@@ -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) {
+1 -1
View File
@@ -11,7 +11,7 @@
const unsubscribe = () => {
if (!intervalId) {
refreshSession();
intervalId = setInterval(refreshSession, 10 * 1000); // 10 minutes
intervalId = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes
}
};
+5 -1
View File
@@ -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;
}
+6
View File
@@ -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();
});
</script>
<svelte:head>
@@ -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;
}