diff --git a/CLAUDE.md b/CLAUDE.md
index 688604b..4c086d5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,6 +25,16 @@ Schwesterprojekt `~/Projekte/logbuch` nachempfunden, jedoch mit **grüner** Grun
(`lib/session.ts`, 8 h Gültigkeit, Cookie `arbeitszeit_session`). Middleware liegt in
`proxy.ts` (Next.js-16-Konvention) und exportiert `middleware`.
+**Passkeys (WebAuthn)**: Zusätzlich zum Passwort (das als Fallback bleibt). `lib/webauthn.ts`
+kapselt `@simplewebauthn/server` (RP-Config aus `RP_ID`/`RP_ORIGIN`/`RP_NAME`, Default
+`localhost`); Credentials liegen in Tabelle `passkeys` (`lib/passkeys.ts`). Die Challenge wird
+kurzlebig in einem httpOnly-Cookie zwischengespeichert. API unter `app/api/passkey/`:
+`register` (GET Options / POST Verify, Session nötig), `authenticate` (GET/POST, **öffentlich** —
+in `proxy.ts` von der Session-Pflicht ausgenommen, setzt bei Erfolg die Session), sowie
+`GET`/`DELETE` auf `app/api/passkey/route.ts` zum Verwalten. Registrierung im Einstellungen-Tab
+(`components/Passkeys.tsx`), Anmeldung per Button auf `app/login/page.tsx`. **WebAuthn braucht
+HTTPS** (Prod via Traefik) bzw. `localhost` in der Entwicklung.
+
**Datenbank**: SQLite3 via `better-sqlite3`. `lib/db.ts` ist ein Singleton, legt Schema und
die `settings`-Tabelle beim ersten Zugriff an. Datei unter `DB_PATH`
(default `./data/arbeitszeit.db`, in Produktion `/app/data/arbeitszeit.db` auf einem Volume).
diff --git a/app/MainClient.tsx b/app/MainClient.tsx
index 13e85b8..b45ccf1 100644
--- a/app/MainClient.tsx
+++ b/app/MainClient.tsx
@@ -5,6 +5,7 @@ import type { ArbeitszeitEintrag } from '@/types/arbeitszeit';
import ArbeitszeitForm from '@/components/ArbeitszeitForm';
import ArbeitszeitList from '@/components/ArbeitszeitList';
import Einstellungen from '@/components/Einstellungen';
+import Passkeys from '@/components/Passkeys';
import packageJson from '@/package.json';
interface Props {
@@ -143,6 +144,7 @@ export default function MainClient({ username }: Props) {
)}
diff --git a/app/api/passkey/authenticate/route.ts b/app/api/passkey/authenticate/route.ts
new file mode 100644
index 0000000..4c23441
--- /dev/null
+++ b/app/api/passkey/authenticate/route.ts
@@ -0,0 +1,29 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { buildAuthenticationOptions, finishAuthentication } from '@/lib/webauthn';
+import { createSession } from '@/lib/session';
+import { getConfiguredUsername } from '@/lib/auth';
+
+// Öffentlich (kein Login): Optionen für die Anmeldung per Passkey.
+export async function GET() {
+ try {
+ return NextResponse.json(await buildAuthenticationOptions());
+ } catch (error) {
+ console.error('GET /api/passkey/authenticate:', error);
+ return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
+ }
+}
+
+// Öffentlich: Verifiziert die Anmelde-Antwort und erstellt bei Erfolg die Session.
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const result = await finishAuthentication(body?.response);
+ if (!result.ok) return NextResponse.json({ error: result.error }, { status: 401 });
+
+ await createSession({ username: getConfiguredUsername(), isAuthenticated: true });
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ console.error('POST /api/passkey/authenticate:', error);
+ return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
+ }
+}
diff --git a/app/api/passkey/register/route.ts b/app/api/passkey/register/route.ts
new file mode 100644
index 0000000..577605d
--- /dev/null
+++ b/app/api/passkey/register/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/session';
+import { buildRegistrationOptions, finishRegistration } from '@/lib/webauthn';
+
+// Optionen für die Registrierung eines neuen Passkeys (Session erforderlich).
+export async function GET() {
+ const session = await getSession();
+ if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
+
+ try {
+ return NextResponse.json(await buildRegistrationOptions());
+ } catch (error) {
+ console.error('GET /api/passkey/register:', error);
+ return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
+ }
+}
+
+// Verifiziert die Registrierungs-Antwort und speichert den Passkey.
+export async function POST(request: NextRequest) {
+ const session = await getSession();
+ if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
+
+ try {
+ const body = await request.json();
+ const result = await finishRegistration(body?.response, String(body?.label ?? ''));
+ if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ console.error('POST /api/passkey/register:', error);
+ return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
+ }
+}
diff --git a/app/api/passkey/route.ts b/app/api/passkey/route.ts
new file mode 100644
index 0000000..357e6da
--- /dev/null
+++ b/app/api/passkey/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSession } from '@/lib/session';
+import { listPasskeys, deletePasskey } from '@/lib/passkeys';
+
+// Liste der registrierten Passkeys (ohne öffentlichen Schlüssel).
+export async function GET() {
+ const session = await getSession();
+ if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
+
+ const passkeys = listPasskeys().map((pk) => ({
+ credentialId: pk.credentialId,
+ label: pk.label,
+ createdAt: pk.createdAt,
+ lastUsedAt: pk.lastUsedAt,
+ }));
+ return NextResponse.json({ passkeys });
+}
+
+// Löscht einen Passkey anhand seiner credentialId (?id=...).
+export async function DELETE(request: NextRequest) {
+ const session = await getSession();
+ if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
+
+ const id = request.nextUrl.searchParams.get('id');
+ if (!id) return NextResponse.json({ error: 'Keine ID angegeben.' }, { status: 400 });
+
+ const removed = deletePasskey(id);
+ if (!removed) return NextResponse.json({ error: 'Passkey nicht gefunden.' }, { status: 404 });
+ return NextResponse.json({ ok: true });
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
index a705c41..bd30f29 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,12 +1,47 @@
'use client';
import { useActionState, useState } from 'react';
+import { startAuthentication } from '@simplewebauthn/browser';
import { login } from './actions';
import packageJson from '@/package.json';
export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined);
const [showPassword, setShowPassword] = useState(false);
+ const [passkeyBusy, setPasskeyBusy] = useState(false);
+ const [passkeyError, setPasskeyError] = useState(null);
+
+ async function handlePasskeyLogin() {
+ setPasskeyError(null);
+ setPasskeyBusy(true);
+ try {
+ const optRes = await fetch('/api/passkey/authenticate');
+ if (!optRes.ok) throw new Error('Optionen konnten nicht geladen werden.');
+ const optionsJSON = await optRes.json();
+
+ const response = await startAuthentication({ optionsJSON });
+
+ const verifyRes = await fetch('/api/passkey/authenticate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ response }),
+ });
+ if (!verifyRes.ok) {
+ const data = await verifyRes.json().catch(() => null);
+ throw new Error(data?.error ?? 'Anmeldung mit Passkey fehlgeschlagen.');
+ }
+ window.location.href = '/';
+ } catch (err) {
+ // Abbruch durch Nutzer (NotAllowedError) nicht als Fehler anzeigen.
+ if (err instanceof Error && err.name === 'NotAllowedError') {
+ setPasskeyError(null);
+ } else {
+ setPasskeyError(err instanceof Error ? err.message : 'Anmeldung mit Passkey fehlgeschlagen.');
+ }
+ } finally {
+ setPasskeyBusy(false);
+ }
+ }
const version = packageJson.version;
const buildDate =
@@ -92,6 +127,30 @@ export default function LoginPage() {
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
+
+
+
+
+
+
+
+ {passkeyBusy ? 'Anmeldung läuft...' : 'Mit Passkey anmelden'}
+
+
+ {passkeyError && (
+
+ {passkeyError}
+
+ )}
diff --git a/components/Passkeys.tsx b/components/Passkeys.tsx
new file mode 100644
index 0000000..e034e1d
--- /dev/null
+++ b/components/Passkeys.tsx
@@ -0,0 +1,152 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { startRegistration } from '@simplewebauthn/browser';
+
+interface Passkey {
+ credentialId: string;
+ label: string;
+ createdAt: string;
+ lastUsedAt: string | null;
+}
+
+function formatDate(iso: string | null): string {
+ if (!iso) return '—';
+ // SQLite liefert "YYYY-MM-DD HH:MM:SS" (UTC).
+ const d = new Date(iso.replace(' ', 'T') + 'Z');
+ if (isNaN(d.getTime())) return '—';
+ return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
+}
+
+export default function Passkeys() {
+ const [passkeys, setPasskeys] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [label, setLabel] = useState('');
+
+ function load() {
+ return fetch('/api/passkey')
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error())))
+ .then((data) => setPasskeys(data.passkeys ?? []))
+ .catch(() => setError('Passkeys konnten nicht geladen werden.'))
+ .finally(() => setLoading(false));
+ }
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ async function handleRegister() {
+ setError(null);
+ setBusy(true);
+ try {
+ const optRes = await fetch('/api/passkey/register');
+ if (!optRes.ok) throw new Error('Optionen konnten nicht geladen werden.');
+ const optionsJSON = await optRes.json();
+
+ const response = await startRegistration({ optionsJSON });
+
+ const verifyRes = await fetch('/api/passkey/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ response, label }),
+ });
+ if (!verifyRes.ok) {
+ const data = await verifyRes.json().catch(() => null);
+ throw new Error(data?.error ?? 'Passkey konnte nicht registriert werden.');
+ }
+ setLabel('');
+ await load();
+ } catch (err) {
+ if (err instanceof Error && err.name === 'NotAllowedError') {
+ setError(null); // Abbruch durch Nutzer
+ } else {
+ setError(err instanceof Error ? err.message : 'Passkey konnte nicht registriert werden.');
+ }
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleDelete(credentialId: string) {
+ if (!confirm('Diesen Passkey wirklich entfernen?')) return;
+ setError(null);
+ try {
+ const res = await fetch(`/api/passkey?id=${encodeURIComponent(credentialId)}`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) throw new Error();
+ await load();
+ } catch {
+ setError('Passkey konnte nicht entfernt werden.');
+ }
+ }
+
+ return (
+
+
Passkeys
+
+ Melde dich künftig per Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Das Passwort
+ bleibt als Alternative bestehen.
+
+
+ {loading ? (
+
Lädt…
+ ) : passkeys.length === 0 ? (
+
Noch keine Passkeys registriert.
+ ) : (
+
+ {passkeys.map((pk) => (
+
+
+
{pk.label}
+
+ Erstellt {formatDate(pk.createdAt)} · Zuletzt {formatDate(pk.lastUsedAt)}
+
+
+ handleDelete(pk.credentialId)}
+ className="text-xs px-2 py-1 text-red-600 hover:text-red-800 shrink-0"
+ >
+ Entfernen
+
+
+ ))}
+
+ )}
+
+
+
+
+ Bezeichnung (optional)
+
+ setLabel(e.target.value)}
+ placeholder="z. B. iPhone von Birgit"
+ maxLength={80}
+ className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-green-600 focus:outline-none"
+ />
+
+
+ {busy ? 'Läuft…' : 'Passkey hinzufügen'}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/lib/db.ts b/lib/db.ts
index 0850028..78685a6 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -37,6 +37,16 @@ export function getDb(): Database.Database {
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
+
+ CREATE TABLE IF NOT EXISTS passkeys (
+ credential_id TEXT PRIMARY KEY,
+ public_key BLOB NOT NULL,
+ counter INTEGER NOT NULL DEFAULT 0,
+ transports TEXT NOT NULL DEFAULT '[]',
+ label TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ last_used_at TEXT
+ );
`);
// Standard-Pause beim ersten Start setzen (0,5 h)
diff --git a/lib/passkeys.ts b/lib/passkeys.ts
new file mode 100644
index 0000000..e97a29e
--- /dev/null
+++ b/lib/passkeys.ts
@@ -0,0 +1,94 @@
+import { getDb } from './db';
+
+/** Ein gespeicherter Passkey (WebAuthn-Credential) für den Single-User. */
+export interface PasskeyRecord {
+ credentialId: string; // base64url
+ publicKey: Uint8Array;
+ counter: number;
+ transports: string[];
+ label: string;
+ createdAt: string;
+ lastUsedAt: string | null;
+}
+
+interface PasskeyRow {
+ credential_id: string;
+ public_key: Buffer;
+ counter: number;
+ transports: string;
+ label: string;
+ created_at: string;
+ last_used_at: string | null;
+}
+
+function fromRow(row: PasskeyRow): PasskeyRecord {
+ let transports: string[] = [];
+ try {
+ const parsed = JSON.parse(row.transports);
+ if (Array.isArray(parsed)) transports = parsed;
+ } catch {
+ /* ignore malformed transports */
+ }
+ return {
+ credentialId: row.credential_id,
+ // Uint8Array.from kopiert in einen frischen ArrayBuffer (passt zum von
+ // @simplewebauthn erwarteten Uint8Array).
+ publicKey: Uint8Array.from(row.public_key),
+ counter: row.counter,
+ transports,
+ label: row.label,
+ createdAt: row.created_at,
+ lastUsedAt: row.last_used_at,
+ };
+}
+
+/** Alle Passkeys, neueste zuerst. */
+export function listPasskeys(): PasskeyRecord[] {
+ return (
+ getDb().prepare('SELECT * FROM passkeys ORDER BY created_at DESC').all() as PasskeyRow[]
+ ).map(fromRow);
+}
+
+export function getPasskey(credentialId: string): PasskeyRecord | undefined {
+ const row = getDb()
+ .prepare('SELECT * FROM passkeys WHERE credential_id = ?')
+ .get(credentialId) as PasskeyRow | undefined;
+ return row ? fromRow(row) : undefined;
+}
+
+export function insertPasskey(record: {
+ credentialId: string;
+ publicKey: Uint8Array;
+ counter: number;
+ transports: string[];
+ label: string;
+}): void {
+ getDb()
+ .prepare(
+ `INSERT INTO passkeys (credential_id, public_key, counter, transports, label)
+ VALUES (@credentialId, @publicKey, @counter, @transports, @label)`
+ )
+ .run({
+ credentialId: record.credentialId,
+ publicKey: Buffer.from(record.publicKey),
+ counter: record.counter,
+ transports: JSON.stringify(record.transports ?? []),
+ label: record.label,
+ });
+}
+
+/** Aktualisiert Counter und last_used_at nach erfolgreicher Anmeldung (Replay-Schutz). */
+export function updatePasskeyUsage(credentialId: string, counter: number): void {
+ getDb()
+ .prepare(
+ `UPDATE passkeys SET counter = @counter, last_used_at = datetime('now')
+ WHERE credential_id = @credentialId`
+ )
+ .run({ credentialId, counter });
+}
+
+export function deletePasskey(credentialId: string): boolean {
+ return (
+ getDb().prepare('DELETE FROM passkeys WHERE credential_id = ?').run(credentialId).changes > 0
+ );
+}
diff --git a/lib/webauthn.ts b/lib/webauthn.ts
new file mode 100644
index 0000000..2b12501
--- /dev/null
+++ b/lib/webauthn.ts
@@ -0,0 +1,164 @@
+import { cookies } from 'next/headers';
+import {
+ generateRegistrationOptions,
+ verifyRegistrationResponse,
+ generateAuthenticationOptions,
+ verifyAuthenticationResponse,
+} from '@simplewebauthn/server';
+import type {
+ RegistrationResponseJSON,
+ AuthenticationResponseJSON,
+ AuthenticatorTransportFuture,
+} from '@simplewebauthn/server';
+import { getConfiguredUsername } from './auth';
+import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys';
+
+const REG_CHALLENGE_COOKIE = 'arbeitszeit_pk_reg';
+const AUTH_CHALLENGE_COOKIE = 'arbeitszeit_pk_auth';
+const CHALLENGE_TTL_S = 300; // 5 Minuten
+
+/**
+ * Relying-Party-Konfiguration. In Produktion über Env-Vars gesetzt
+ * (RP_ID = Host, RP_ORIGIN = volle URL). Default-Werte für lokale Entwicklung.
+ */
+export function getRpConfig() {
+ const rpID = process.env.RP_ID || 'localhost';
+ const rpName = process.env.RP_NAME || 'Arbeitszeiterfassung';
+ const origin = process.env.RP_ORIGIN || 'http://localhost:3000';
+ return { rpID, rpName, origin };
+}
+
+// --- Challenge-Cookie (kurzlebig, httpOnly) ------------------------------
+
+async function setChallengeCookie(name: string, challenge: string): Promise {
+ const cookieStore = await cookies();
+ cookieStore.set(name, challenge, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ maxAge: CHALLENGE_TTL_S,
+ });
+}
+
+async function takeChallengeCookie(name: string): Promise {
+ const cookieStore = await cookies();
+ const value = cookieStore.get(name)?.value ?? null;
+ cookieStore.delete(name);
+ return value;
+}
+
+// --- Registrierung (Enrollment, Session erforderlich) --------------------
+
+export async function buildRegistrationOptions() {
+ const { rpID, rpName } = getRpConfig();
+ const existing = listPasskeys();
+
+ const options = await generateRegistrationOptions({
+ rpName,
+ rpID,
+ userName: getConfiguredUsername(),
+ attestationType: 'none',
+ excludeCredentials: existing.map((pk) => ({
+ id: pk.credentialId,
+ transports: pk.transports as AuthenticatorTransportFuture[],
+ })),
+ authenticatorSelection: {
+ residentKey: 'preferred',
+ userVerification: 'preferred',
+ },
+ });
+
+ await setChallengeCookie(REG_CHALLENGE_COOKIE, options.challenge);
+ return options;
+}
+
+/** Verifiziert die Registrierungs-Antwort und speichert den Passkey. */
+export async function finishRegistration(
+ response: RegistrationResponseJSON,
+ label: string
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const expectedChallenge = await takeChallengeCookie(REG_CHALLENGE_COOKIE);
+ if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
+
+ const { rpID, origin } = getRpConfig();
+ let verification;
+ try {
+ verification = await verifyRegistrationResponse({
+ response,
+ expectedChallenge,
+ expectedOrigin: origin,
+ expectedRPID: rpID,
+ requireUserVerification: false,
+ });
+ } catch {
+ return { ok: false, error: 'Passkey konnte nicht verifiziert werden.' };
+ }
+
+ if (!verification.verified || !verification.registrationInfo) {
+ return { ok: false, error: 'Passkey-Registrierung fehlgeschlagen.' };
+ }
+
+ const { credential } = verification.registrationInfo;
+ if (getPasskey(credential.id)) {
+ return { ok: false, error: 'Dieser Passkey ist bereits registriert.' };
+ }
+
+ insertPasskey({
+ credentialId: credential.id,
+ publicKey: credential.publicKey,
+ counter: credential.counter,
+ transports: credential.transports ?? [],
+ label: label.trim().slice(0, 80) || 'Passkey',
+ });
+ return { ok: true };
+}
+
+// --- Anmeldung (öffentlich, keine Session) -------------------------------
+
+export async function buildAuthenticationOptions() {
+ const { rpID } = getRpConfig();
+ const options = await generateAuthenticationOptions({
+ rpID,
+ userVerification: 'preferred',
+ // allowCredentials leer lassen → erlaubt Discoverable Credentials / Browser-Auswahl.
+ });
+ await setChallengeCookie(AUTH_CHALLENGE_COOKIE, options.challenge);
+ return options;
+}
+
+/** Verifiziert die Anmelde-Antwort gegen einen gespeicherten Passkey. */
+export async function finishAuthentication(
+ response: AuthenticationResponseJSON
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const expectedChallenge = await takeChallengeCookie(AUTH_CHALLENGE_COOKIE);
+ if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
+
+ const passkey = getPasskey(response.id);
+ if (!passkey) return { ok: false, error: 'Unbekannter Passkey.' };
+
+ const { rpID, origin } = getRpConfig();
+ let verification;
+ try {
+ verification = await verifyAuthenticationResponse({
+ response,
+ expectedChallenge,
+ expectedOrigin: origin,
+ expectedRPID: rpID,
+ requireUserVerification: false,
+ credential: {
+ id: passkey.credentialId,
+ publicKey: passkey.publicKey,
+ counter: passkey.counter,
+ transports: passkey.transports as AuthenticatorTransportFuture[],
+ },
+ });
+ } catch {
+ return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
+ }
+
+ if (!verification.verified) return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
+
+ updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter);
+ return { ok: true };
+}
diff --git a/package-lock.json b/package-lock.json
index d83af6d..80cc359 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "arbeitszeit",
"version": "0.1.0",
"dependencies": {
+ "@simplewebauthn/browser": "^13.3.0",
+ "@simplewebauthn/server": "^13.3.1",
"better-sqlite3": "^11.8.1",
"jose": "^6.2.2",
"next": "16.1.6",
@@ -280,30 +282,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -459,6 +437,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@hexagon/base64": {
+ "version": "1.1.28",
+ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
+ "license": "MIT"
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1041,6 +1025,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@levischuck/tiny-cbor": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
+ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -1252,6 +1242,174 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@peculiar/asn1-android": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
+ "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-cms": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
+ "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "@peculiar/asn1-x509-attr": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-csr": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
+ "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
+ "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pfx": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
+ "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.8.0",
+ "@peculiar/asn1-pkcs8": "^2.8.0",
+ "@peculiar/asn1-rsa": "^2.8.0",
+ "@peculiar/asn1-schema": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs8": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
+ "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs9": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
+ "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.8.0",
+ "@peculiar/asn1-pfx": "^2.8.0",
+ "@peculiar/asn1-pkcs8": "^2.8.0",
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "@peculiar/asn1-x509-attr": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
+ "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
+ "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
+ "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-attr": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
+ "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.8.0",
+ "@peculiar/asn1-x509": "^2.8.0",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/x509": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
+ "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.6.0",
+ "@peculiar/asn1-csr": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.0",
+ "@peculiar/asn1-pkcs9": "^2.6.0",
+ "@peculiar/asn1-rsa": "^2.6.0",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.0",
+ "pvtsutils": "^1.3.6",
+ "reflect-metadata": "^0.2.2",
+ "tslib": "^2.8.1",
+ "tsyringe": "^4.10.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1259,6 +1417,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "13.3.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
+ "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
+ "license": "MIT"
+ },
+ "node_modules/@simplewebauthn/server": {
+ "version": "13.3.1",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz",
+ "integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==",
+ "license": "MIT",
+ "dependencies": {
+ "@hexagon/base64": "^1.1.27",
+ "@levischuck/tiny-cbor": "^0.2.2",
+ "@peculiar/asn1-android": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.1",
+ "@peculiar/asn1-rsa": "^2.6.1",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.1",
+ "@peculiar/x509": "^1.14.3"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2466,6 +2649,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asn1js": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.5",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -3470,6 +3667,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5810,6 +6008,24 @@
"node": ">=6"
}
},
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+ "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5899,6 +6115,12 @@
"node": ">= 6"
}
},
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "license": "Apache-2.0"
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6745,6 +6967,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tsyringe": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.9.3"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/tsyringe/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
diff --git a/package.json b/package.json
index ad618c6..2c8cc5c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "arbeitszeit",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -9,6 +9,8 @@
"lint": "eslint"
},
"dependencies": {
+ "@simplewebauthn/browser": "^13.3.0",
+ "@simplewebauthn/server": "^13.3.1",
"better-sqlite3": "^11.8.1",
"jose": "^6.2.2",
"next": "16.1.6",
diff --git a/proxy.ts b/proxy.ts
index b3c48af..f1c1c5f 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -10,7 +10,13 @@ const key = new TextEncoder().encode(secretKey);
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
- if (pathname.startsWith('/login') || pathname.startsWith('/_next') || pathname.startsWith('/favicon')) {
+ if (
+ pathname.startsWith('/login') ||
+ pathname.startsWith('/_next') ||
+ pathname.startsWith('/favicon') ||
+ // Passkey-Anmeldung muss ohne Session erreichbar sein.
+ pathname.startsWith('/api/passkey/authenticate')
+ ) {
return NextResponse.next();
}