From e35330206a75c48516e5aadeba8c104e62010ecc Mon Sep 17 00:00:00 2001 From: mahdiwafy Date: Thu, 30 Jul 2026 09:08:56 +0700 Subject: [PATCH 1/3] feat: WebAuthn passkey authentication with IP-conditional remote login - New passkeys table & migration v5 (credential ID, publicKey, counter, etc.) - WebAuthn ceremony API routes: register start/finish, login start/finish, manage - IP detection via isLocalRequest: remoteAuthMode controls login options remotely - Login page: passkey button when passkeys are available - Masuk page: same passkey support in Indonesian locale - Profile page: full passkey management (register, list, delete, remote mode selector) - Uses @simplewebauthn/server + @simplewebauthn/browser v13 --- .gitignore | 1 + package.json | 5 +- pnpm-lock.yaml | 85 +++++++ src/app/(dashboard)/dashboard/profile/page.js | 222 ++++++++++++++++++ .../api/auth/passkey/login/finish/route.js | 31 +++ src/app/api/auth/passkey/login/start/route.js | 18 ++ src/app/api/auth/passkey/manage/route.js | 53 +++++ .../api/auth/passkey/register/finish/route.js | 27 +++ .../api/auth/passkey/register/start/route.js | 22 ++ src/app/api/auth/status/route.js | 17 +- src/app/login/page.js | 81 ++++++- src/app/masuk/MasukClient.js | 78 +++++- src/app/masuk/page.js | 13 +- src/dashboardGuard.js | 1 + src/lib/auth/passkeyBrowser.js | 7 + src/lib/auth/passkeys.js | 179 ++++++++++++++ src/lib/db/migrations/005-add-passkeys.js | 19 ++ src/lib/db/migrations/index.js | 5 +- src/lib/db/repos/passkeysRepo.js | 78 ++++++ src/lib/db/repos/settingsRepo.js | 2 + src/lib/db/schema.js | 15 +- 21 files changed, 941 insertions(+), 18 deletions(-) create mode 100644 src/app/api/auth/passkey/login/finish/route.js create mode 100644 src/app/api/auth/passkey/login/start/route.js create mode 100644 src/app/api/auth/passkey/manage/route.js create mode 100644 src/app/api/auth/passkey/register/finish/route.js create mode 100644 src/app/api/auth/passkey/register/start/route.js create mode 100644 src/lib/auth/passkeyBrowser.js create mode 100644 src/lib/auth/passkeys.js create mode 100644 src/lib/db/migrations/005-add-passkeys.js create mode 100644 src/lib/db/repos/passkeysRepo.js diff --git a/.gitignore b/.gitignore index 8b576f04c5..863399c555 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,4 @@ next-env.d.ts .graphifyignore graphify-out/* .next-analyze/* +.claude/ diff --git a/package.json b/package.json index 845406bfee..33d922a8f3 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,16 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", - "@swc/helpers": "^0.5.15", "@next/env": "^16.1.6", "@next/third-parties": "^16.2.9", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", + "@swc/helpers": "^0.5.15", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", "confbox": "^0.2.4", "dompurify": "^3.2.7", + "duck-duck-scrape": "^2.2.7", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 298e0cf0ee..f9c7d20fc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@next/third-parties': specifier: ^16.2.9 version: 16.2.9(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 + '@simplewebauthn/server': + specifier: ^13.3.2 + version: 13.3.2 '@swc/helpers': specifier: ^0.5.15 version: 0.5.15 @@ -44,6 +50,9 @@ importers: dompurify: specifier: ^3.2.7 version: 3.2.7 + duck-duck-scrape: + specifier: ^2.2.7 + version: 2.2.7 express: specifier: ^5.2.1 version: 5.2.1 @@ -282,6 +291,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -471,6 +483,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@monaco-editor/loader@1.7.0': resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} @@ -571,6 +586,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@peculiar/asn1-android@2.8.0': + resolution: {integrity: sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==} + '@peculiar/asn1-cms@2.8.0': resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} @@ -622,6 +640,13 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.2': + resolution: {integrity: sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==} + engines: {node: '>=20.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1371,6 +1396,9 @@ packages: dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + duck-duck-scrape@2.2.7: + resolution: {integrity: sha512-BEcglwnfx5puJl90KQfX+Q2q5vCguqyMpZcSRPBWk8OY55qWwV93+E+7DbIkrGDW4qkqPfUvtOUdi0lXz6lEMQ==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1754,6 +1782,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1766,6 +1797,10 @@ packages: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2197,6 +2232,11 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + needle@3.5.0: + resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==} + engines: {node: '>= 4.4.x'} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -2526,6 +2566,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3130,6 +3174,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3262,6 +3308,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@levischuck/tiny-cbor@0.2.11': {} + '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 @@ -3332,6 +3380,12 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@peculiar/asn1-android@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + '@peculiar/asn1-cms@2.8.0': dependencies: '@peculiar/asn1-schema': 2.8.0 @@ -3440,6 +3494,19 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.2': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/x509': 1.14.3 + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -4164,6 +4231,11 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + duck-duck-scrape@2.2.7: + dependencies: + html-entities: 2.6.0 + needle: 3.5.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4714,6 +4786,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-entities@2.6.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4741,6 +4815,10 @@ snapshots: transitivePeerDependencies: - debug + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5102,6 +5180,11 @@ snapshots: natural-compare@1.4.0: {} + needle@3.5.0: + dependencies: + iconv-lite: 0.6.3 + sax: 1.6.0 + negotiator@1.0.0: {} next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -5493,6 +5576,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.6.0: {} + scheduler@0.27.0: {} selfsigned@5.5.0: diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 2d0ccf2076..8f27cc01fe 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -57,6 +57,11 @@ export default function ProfilePage() { const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); const [proxyLoading, setProxyLoading] = useState(false); const [proxyTestLoading, setProxyTestLoading] = useState(false); + const [passkeys, setPasskeys] = useState([]); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeyStatus, setPasskeyStatus] = useState({ type: "", message: "" }); + const [passkeyNickname, setPasskeyNickname] = useState(""); + const [remoteAuthModeForm, setRemoteAuthModeForm] = useState("password"); /* eslint-disable react-hooks/set-state-in-effect -- Locale sync on mount and bootstrap fetch of /api/settings. setState is @@ -70,6 +75,7 @@ export default function ProfilePage() { .then((res) => res.json()) .then((data) => { setSettings(data); + setRemoteAuthModeForm(data?.remoteAuthMode || "password"); setOidcForm({ authMode: data?.authMode || "password", oidcIssuerUrl: data?.oidcIssuerUrl || "", @@ -230,6 +236,97 @@ export default function ProfilePage() { } }; + // Fetch registered passkeys + const fetchPasskeys = async () => { + try { + const res = await fetch("/api/auth/passkey/manage"); + if (res.ok) { + const data = await res.json(); + setPasskeys(data.passkeys || []); + } + } catch {} + }; + + useEffect(() => { + fetchPasskeys(); + }, []); + + const handlePasskeyRegister = async () => { + setPasskeyLoading(true); + setPasskeyStatus({ type: "", message: "" }); + try { + const startRes = await fetch("/api/auth/passkey/register/start", { method: "POST" }); + if (!startRes.ok) { + const data = await startRes.json(); + setPasskeyStatus({ type: "error", message: data.error || "Failed to start registration" }); + return; + } + const options = await startRes.json(); + + const { startRegistration } = await import("@/lib/auth/passkeyBrowser.js"); + const credential = await startRegistration({ responseJSON: options }); + + const finishRes = await fetch("/api/auth/passkey/register/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credential, nickname: passkeyNickname || null }), + }); + + if (finishRes.ok) { + setPasskeyStatus({ type: "success", message: "Passkey registered successfully" }); + setPasskeyNickname(""); + fetchPasskeys(); + // Refresh settings to reflect passkeysEnabled + const settingsRes = await fetch("/api/settings"); + if (settingsRes.ok) setSettings(await settingsRes.json()); + } else { + const data = await finishRes.json(); + setPasskeyStatus({ type: "error", message: data.error || "Failed to register passkey" }); + } + } catch (err) { + if (err.name === "NotAllowedError") { + setPasskeyStatus({ type: "error", message: "Registration was cancelled or timed out" }); + } else { + setPasskeyStatus({ type: "error", message: err.message || "An error occurred" }); + } + } finally { + setPasskeyLoading(false); + } + }; + + const handlePasskeyDelete = async (id) => { + try { + const res = await fetch("/api/auth/passkey/manage", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (res.ok) { + setPasskeyStatus({ type: "success", message: "Passkey removed" }); + fetchPasskeys(); + const settingsRes = await fetch("/api/settings"); + if (settingsRes.ok) setSettings(await settingsRes.json()); + } else { + const data = await res.json(); + setPasskeyStatus({ type: "error", message: data.error || "Failed to remove passkey" }); + } + } catch (err) { + setPasskeyStatus({ type: "error", message: "An error occurred" }); + } + }; + + const handleRemoteAuthModeChange = async (mode) => { + setRemoteAuthModeForm(mode); + try { + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ remoteAuthMode: mode }), + }); + setSettings((prev) => ({ ...prev, remoteAuthMode: mode })); + } catch {} + }; + const updateFallbackStrategy = async (strategy) => { try { const res = await fetch("/api/settings", { @@ -755,6 +852,131 @@ export default function ProfilePage() { + {/* Passkey Authentication */} + +
+
+ key +
+
+

Passkey Authentication

+

+ Sign in with biometrics or security keys (WebAuthn / FIDO2). Works with Google Password Manager, Apple iCloud Keychain, and Windows Hello. +

+
+
+ +
+ {/* Remote auth mode selector */} +
+ +

+ What login methods are available to non-localhost users. Localhost always has full access. +

+
+ {[ + { value: "password", label: "Password only" }, + { value: "passkey", label: "Passkey only" }, + { value: "both", label: "Password + Passkey" }, + ].map((opt) => ( + + ))} +
+ {passkeys.length === 0 && remoteAuthModeForm !== "password" && ( +

+ Register at least one passkey before switching to passkey-only mode. +

+ )} +
+ +
+ + {/* Register new passkey */} +
+ +
+ setPasskeyNickname(e.target.value)} + className="flex-1" + /> + +
+ {passkeyStatus.message && ( +

+ {passkeyStatus.message} +

+ )} +

+ Requires HTTPS or localhost. Your browser will prompt you to create a passkey. +

+
+ + {/* Registered passkeys list */} + {passkeys.length > 0 && ( +
+ +
+ {passkeys.map((pk) => ( +
+ + {pk.transports?.includes("internal") ? "fingerprint" : "usb"} + +
+

+ {pk.nickname || `Passkey (${pk.id.slice(0, 8)}...)`} +

+

+ {pk.deviceType === "multiDevice" ? "Cross-device" : "Single device"} + {pk.lastUsedAt ? ` · Last used: ${new Date(pk.lastUsedAt).toLocaleDateString()}` : " · Never used"} +

+
+ +
+ ))} +
+
+ )} + + {passkeys.length === 0 && ( +

+ No passkeys registered yet. Add one to enable passwordless login. +

+ )} +
+ + {/* OIDC */}
@@ -182,13 +236,30 @@ export default function LoginPage() { )} - {oidcAvailable && passwordAvailable &&
} + {oidcAvailable && (passwordAvailable || passkeyAvailable) &&
} + + {passkeyAvailable && ( + + )} + + {passkeyAvailable && passwordAvailable &&
} {passwordAvailable ? (
{((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && (

- OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery. + OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available.

)} @@ -206,7 +277,7 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} required - autoFocus={!oidcAvailable} + autoFocus={!oidcAvailable && !passkeyAvailable} /> {error &&

{error}

} {retryAfter > 0 && ( diff --git a/src/app/masuk/MasukClient.js b/src/app/masuk/MasukClient.js index d4dd383653..fac256865b 100644 --- a/src/app/masuk/MasukClient.js +++ b/src/app/masuk/MasukClient.js @@ -26,6 +26,11 @@ export default function MasukClient({ initialAuth }) { const authMode = initialAuth?.authMode || "password"; const oidcConfigured = initialAuth?.oidcConfigured || false; const oidcLoginLabel = initialAuth?.oidcLoginLabel || "Masuk dengan OIDC"; + const isLocal = initialAuth?.isLocal === true; + const passkeysEnabled = initialAuth?.passkeysEnabled === true; + const remoteAuthMode = initialAuth?.remoteAuthMode || "password"; + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeyError, setPasskeyError] = useState(""); const router = useRouter(); useEffect(() => { @@ -62,8 +67,50 @@ export default function MasukClient({ initialAuth }) { } }; + const handlePasskeyLogin = async () => { + setPasskeyLoading(true); + setPasskeyError(""); + try { + const startRes = await fetch("/api/auth/passkey/login/start", { method: "POST" }); + if (!startRes.ok) { + const data = await startRes.json(); + setPasskeyError(data.error || "Login passkey gagal"); + return; + } + const options = await startRes.json(); + + const { startAuthentication } = await import("@/lib/auth/passkeyBrowser.js"); + const assertion = await startAuthentication({ responseJSON: options }); + + const finishRes = await fetch("/api/auth/passkey/login/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ assertion }), + }); + + if (finishRes.ok) { + router.push("/dashboard"); + router.refresh(); + } else { + const data = await finishRes.json(); + setPasskeyError(data.error || "Verifikasi passkey gagal"); + } + } catch (err) { + if (err.name === "NotAllowedError") { + setPasskeyError("Autentikasi passkey dibatalkan atau kedaluwarsa."); + } else { + setPasskeyError(err.message || "Terjadi kesalahan saat login passkey."); + } + } finally { + setPasskeyLoading(false); + } + }; + const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode); - const passwordAvailable = authMode !== "oidc" || !oidcConfigured; + const passkeyAvailable = passkeysEnabled && (isLocal || ["passkey", "both"].includes(remoteAuthMode)); + const passwordAvailable = isLocal + ? (authMode !== "oidc" || !oidcConfigured) + : (remoteAuthMode !== "passkey" && (authMode !== "oidc" || !oidcConfigured)); if (hasPassword === null) { return ( @@ -93,7 +140,9 @@ export default function MasukClient({ initialAuth }) {

{authMode === "oidc" && oidcConfigured ? "Masuk dengan OIDC provider untuk mengakses dashboard" - : "Masukkan password untuk mengakses dashboard"} + : passkeyAvailable && !passwordAvailable + ? "Masuk dengan passkey untuk mengakses dashboard" + : "Masukkan password untuk mengakses dashboard"}

@@ -105,7 +154,26 @@ export default function MasukClient({ initialAuth }) { )} - {oidcAvailable && passwordAvailable &&
} + {oidcAvailable && (passwordAvailable || passkeyAvailable) &&
} + + {passkeyAvailable && ( + + )} + + {passkeyAvailable && passwordAvailable &&
} + + {passkeyError &&

{passkeyError}

} {passwordAvailable ? ( @@ -130,7 +198,7 @@ export default function MasukClient({ initialAuth }) { value={password} onChange={(e) => setPassword(e.target.value)} required - autoFocus={!oidcAvailable} + autoFocus={!oidcAvailable && !passkeyAvailable} /> {error &&

{error}

} {retryAfter > 0 && ( @@ -172,4 +240,4 @@ export default function MasukClient({ initialAuth }) {
); -} \ No newline at end of file +} diff --git a/src/app/masuk/page.js b/src/app/masuk/page.js index ce46e97ffb..6d03eec9f5 100644 --- a/src/app/masuk/page.js +++ b/src/app/masuk/page.js @@ -1,16 +1,24 @@ import { cookies } from "next/headers"; +import { headers } from "next/headers"; import { getSettings } from "@/lib/localDb"; import { isOidcConfigured } from "@/lib/auth/oidc"; import { getDashboardAuthSession } from "@/lib/auth/dashboardSession"; +import { isLocalRequest } from "@/dashboardGuard"; import MasukClient from "./MasukClient"; export default async function MasukPage() { - let initialAuth = { hasPassword: true, authMode: "password", oidcConfigured: false, oidcLoginLabel: "Masuk dengan OIDC", requireLogin: true }; + let initialAuth = { hasPassword: true, authMode: "password", oidcConfigured: false, oidcLoginLabel: "Masuk dengan OIDC", requireLogin: true, isLocal: false, passkeysEnabled: false, remoteAuthMode: "password" }; try { const settings = await getSettings(); const cookieStore = await cookies(); + const headerStore = await headers(); const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value); const requireLogin = settings.requireLogin !== false; + + // Build a request-like object for isLocalRequest + const req = { headers: headerStore }; + const local = isLocalRequest(req); + initialAuth = { requireLogin, authMode: settings.authMode || "password", @@ -18,6 +26,9 @@ export default async function MasukPage() { oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC", hasPassword: !!settings.password, isLoggedIn: !!session, + isLocal: local, + passkeysEnabled: !!settings.passkeysEnabled, + remoteAuthMode: settings.remoteAuthMode || "password", }; } catch {} return ; diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index e1e3db589d..c61665fc5f 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -27,6 +27,7 @@ const PUBLIC_API_PATHS = [ "/api/auth/logout", "/api/auth/status", "/api/auth/oidc", + "/api/auth/passkey/login", "/api/version", "/api/settings/require-login", ]; diff --git a/src/lib/auth/passkeyBrowser.js b/src/lib/auth/passkeyBrowser.js new file mode 100644 index 0000000000..3f3960dd94 --- /dev/null +++ b/src/lib/auth/passkeyBrowser.js @@ -0,0 +1,7 @@ +/** + * Client-side WebAuthn helpers using @simplewebauthn/browser. + * Dynamically imported to avoid loading the library on pages that don't use it. + */ +import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; + +export { startAuthentication, startRegistration }; diff --git a/src/lib/auth/passkeys.js b/src/lib/auth/passkeys.js new file mode 100644 index 0000000000..6c67b022fd --- /dev/null +++ b/src/lib/auth/passkeys.js @@ -0,0 +1,179 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import { getPasskeys, getPasskeyById, createPasskey, updatePasskeyCounter, deletePasskey } from "@/lib/db/repos/passkeysRepo.js"; +import { getSettings, updateSettings } from "@/lib/db/repos/settingsRepo.js"; +import { getPublicOrigin } from "@/lib/auth/oidc.js"; + +/** + * Determine the Relying Party (RP) configuration from the request origin. + * WebAuthn requires the RP ID to match the domain the browser is on. + */ +function getRpConfig(request) { + const origin = getPublicOrigin(request); + let rpId; + try { + rpId = new URL(origin).hostname; + } catch { + // Fallback: use the Host header + rpId = (request.headers.get("host") || "localhost").split(":")[0]; + } + // For localhost, WebAuthn requires rpId to be "localhost" + if (rpId === "127.0.0.1" || rpId === "::1") rpId = "localhost"; + return { rpId, origin }; +} + +/** + * Generate registration options for a new passkey. + * Called when the user wants to register a passkey from the profile page. + */ +export async function startPasskeyRegistration(request) { + const { rpId } = getRpConfig(request); + + const existingPasskeys = await getPasskeys(); + const excludedCredentials = existingPasskeys.map((pk) => ({ + id: pk.id, + type: "public-key", + transports: pk.transports, + })); + + const options = await generateRegistrationOptions({ + rpName: "VansRouter", + rpID: rpId, + userName: "VansRouter Admin", + userDisplayName: "VansRouter Dashboard", + excludeCredentials: excludedCredentials, + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + return options; +} + +/** + * Verify the registration response and store the new passkey. + */ +export async function finishPasskeyRegistration(request, credential, nickname) { + const { rpId, origin } = getRpConfig(request); + const expectedChallenge = credential.challenge; + + const verification = await verifyRegistrationResponse({ + response: credential, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + }); + + if (!verification.verified || !verification.registrationInfo) { + throw new Error("Registration verification failed"); + } + + const { credential: cred } = verification.registrationInfo; + await createPasskey({ + id: cred.id, + publicKey: Buffer.from(cred.publicKey).toString("base64url"), + counter: cred.counter, + transports: cred.transports || [], + deviceType: verification.registrationInfo.credentialDeviceType || "singleDevice", + nickname: nickname || null, + }); + + // Auto-enable passkeys when the first one is registered + const count = (await getPasskeys()).length; + if (count > 0) { + const settings = await getSettings(); + if (!settings.passkeysEnabled) { + await updateSettings({ passkeysEnabled: true }); + } + } + + return { verified: true, id: cred.id }; +} + +/** + * Generate authentication options for passkey login. + */ +export async function startPasskeyLogin(request) { + const { rpId } = getRpConfig(request); + + const passkeys = await getPasskeys(); + const allowCredentials = passkeys.map((pk) => ({ + id: pk.id, + type: "public-key", + transports: pk.transports, + })); + + const options = await generateAuthenticationOptions({ + rpID: rpId, + allowCredentials, + userVerification: "preferred", + }); + + return options; +} + +/** + * Verify the authentication response and return success if valid. + */ +export async function finishPasskeyLogin(request, assertion) { + const { rpId, origin } = getRpConfig(request); + const expectedChallenge = assertion.challenge; + + const passkeys = await getPasskeys(); + if (passkeys.length === 0) { + throw new Error("No passkeys registered"); + } + + // Find the passkey matching the credential ID in the assertion + const credId = assertion.id; + const passkey = passkeys.find((pk) => pk.id === credId); + if (!passkey) { + throw new Error("Passkey not found"); + } + + const verification = await verifyAuthenticationResponse({ + response: assertion, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + credential: { + id: passkey.id, + publicKey: Buffer.from(passkey.publicKey, "base64url"), + counter: passkey.counter, + transports: passkey.transports, + }, + }); + + if (!verification.verified) { + throw new Error("Authentication verification failed"); + } + + // Update the counter for replay protection + await updatePasskeyCounter(passkey.id, verification.authenticationInfo.newCounter); + + return { verified: true, passkeyId: passkey.id }; +} + +/** + * List all registered passkeys (for the profile management UI). + */ +export async function listPasskeys() { + return await getPasskeys(); +} + +/** + * Remove a passkey by ID. + */ +export async function removePasskey(id) { + await deletePasskey(id); + // Auto-disable passkeys when the last one is removed + const count = (await getPasskeys()).length; + if (count === 0) { + await updateSettings({ passkeysEnabled: false, remoteAuthMode: "password" }); + } +} diff --git a/src/lib/db/migrations/005-add-passkeys.js b/src/lib/db/migrations/005-add-passkeys.js new file mode 100644 index 0000000000..3038f8e606 --- /dev/null +++ b/src/lib/db/migrations/005-add-passkeys.js @@ -0,0 +1,19 @@ +export default { + version: 5, + name: "add-passkeys-table", + up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS passkeys ( + id TEXT PRIMARY KEY, + publicKey TEXT NOT NULL, + counter INTEGER DEFAULT 0, + transports TEXT, + deviceType TEXT, + nickname TEXT, + createdAt TEXT NOT NULL, + lastUsedAt TEXT + ) + `); + db.exec("CREATE INDEX IF NOT EXISTS idx_passkey_created ON passkeys(createdAt)"); + }, +}; diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js index c041147b37..973cc7d39a 100644 --- a/src/lib/db/migrations/index.js +++ b/src/lib/db/migrations/index.js @@ -3,10 +3,11 @@ // Versions MUST be unique and monotonically increasing. import m001 from "./001-initial.js"; import m002 from "./002-fix-empty-allowed-lists.js"; -import m003 from "./003-add-allowed-lists-columns.js"; +import m003 from "./003-add-allowed-lists-columns.js"; import m004 from "./004-add-request-details-apikey.js"; +import m005 from "./005-add-passkeys.js"; -export const MIGRATIONS = [m001, m002, m003, m004].sort((a, b) => a.version - b.version); +export const MIGRATIONS = [m001, m002, m003, m004, m005].sort((a, b) => a.version - b.version); export function latestVersion() { return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0; diff --git a/src/lib/db/repos/passkeysRepo.js b/src/lib/db/repos/passkeysRepo.js new file mode 100644 index 0000000000..fa436b354e --- /dev/null +++ b/src/lib/db/repos/passkeysRepo.js @@ -0,0 +1,78 @@ +import { getAdapter } from "../driver.js"; +import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; + +export async function getPasskeys() { + const db = await getAdapter(); + const rows = db.all("SELECT * FROM passkeys ORDER BY createdAt DESC"); + return rows.map((row) => ({ + id: row.id, + publicKey: row.publicKey, + counter: row.counter || 0, + transports: parseJson(row.transports, []), + deviceType: row.deviceType || null, + nickname: row.nickname || null, + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt || null, + })); +} + +export async function getPasskeyById(id) { + const db = await getAdapter(); + const row = db.get("SELECT * FROM passkeys WHERE id = ?", [id]); + if (!row) return null; + return { + id: row.id, + publicKey: row.publicKey, + counter: row.counter || 0, + transports: parseJson(row.transports, []), + deviceType: row.deviceType || null, + nickname: row.nickname || null, + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt || null, + }; +} + +export async function createPasskey(data) { + const db = await getAdapter(); + const now = new Date().toISOString(); + db.run( + `INSERT INTO passkeys (id, publicKey, counter, transports, deviceType, nickname, createdAt, lastUsedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + publicKey = excluded.publicKey, + counter = excluded.counter, + transports = excluded.transports, + deviceType = excluded.deviceType, + nickname = excluded.nickname`, + [ + data.id, + data.publicKey, + data.counter || 0, + stringifyJson(data.transports || []), + data.deviceType || null, + data.nickname || null, + now, + null, + ] + ); + return getPasskeyById(data.id); +} + +export async function updatePasskeyCounter(id, counter, lastUsedAt) { + const db = await getAdapter(); + db.run( + "UPDATE passkeys SET counter = ?, lastUsedAt = ? WHERE id = ?", + [counter, lastUsedAt || new Date().toISOString(), id] + ); +} + +export async function deletePasskey(id) { + const db = await getAdapter(); + db.run("DELETE FROM passkeys WHERE id = ?", [id]); +} + +export async function getPasskeyCount() { + const db = await getAdapter(); + const row = db.get("SELECT COUNT(*) as count FROM passkeys"); + return row?.count || 0; +} diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 7037953d61..c4d78ff0f0 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -21,6 +21,8 @@ const DEFAULT_SETTINGS = { allowRemoteNoApiKey: false, tunnelDashboardAccess: true, authMode: "password", + remoteAuthMode: "password", + passkeysEnabled: false, oidcIssuerUrl: "", oidcClientId: "", oidcClientSecret: "", diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 095187814b..f97c68b9b5 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -3,7 +3,7 @@ // pre-change safety backup in migrate.js: when the stored version is lower, // one lightweight DB backup is taken before applying schema changes. Forgetting // to bump only skips that backup — it does NOT break the additive auto-sync. -export const SCHEMA_VERSION = 2; +export const SCHEMA_VERSION = 6; export const PRAGMA_SQL = ` PRAGMA journal_mode = WAL; @@ -156,6 +156,19 @@ export const TABLES = { "CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)", ], }, + passkeys: { + columns: { + id: "TEXT PRIMARY KEY", + publicKey: "TEXT NOT NULL", + counter: "INTEGER DEFAULT 0", + transports: "TEXT", + deviceType: "TEXT", + nickname: "TEXT", + createdAt: "TEXT NOT NULL", + lastUsedAt: "TEXT", + }, + indexes: ["CREATE INDEX IF NOT EXISTS idx_passkey_created ON passkeys(createdAt)"], + }, cachedProviderModels: { columns: { providerId: "TEXT NOT NULL", From 07ffdbe019f5c70539471f0a7534026a10c1e3bd Mon Sep 17 00:00:00 2001 From: mahdiwafy Date: Thu, 30 Jul 2026 11:01:08 +0700 Subject: [PATCH 2/3] fix: add cookie-based challenge persistence to login finish route and rebuild on CI failure --- src/app/(dashboard)/dashboard/profile/page.js | 2 +- src/app/api/auth/passkey/login/finish/route.js | 16 +++++++++++++++- src/app/api/auth/passkey/login/start/route.js | 13 ++++++++++++- .../api/auth/passkey/register/finish/route.js | 17 ++++++++++++++++- .../api/auth/passkey/register/start/route.js | 13 ++++++++++++- src/app/login/page.js | 2 +- src/app/masuk/MasukClient.js | 2 +- 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 8f27cc01fe..233603f0ca 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -264,7 +264,7 @@ export default function ProfilePage() { const options = await startRes.json(); const { startRegistration } = await import("@/lib/auth/passkeyBrowser.js"); - const credential = await startRegistration({ responseJSON: options }); + const credential = await startRegistration({ optionsJSON: options }); const finishRes = await fetch("/api/auth/passkey/register/finish", { method: "POST", diff --git a/src/app/api/auth/passkey/login/finish/route.js b/src/app/api/auth/passkey/login/finish/route.js index 9c523c228a..aa340ff060 100644 --- a/src/app/api/auth/passkey/login/finish/route.js +++ b/src/app/api/auth/passkey/login/finish/route.js @@ -17,11 +17,25 @@ export async function POST(request) { return NextResponse.json({ error: "Missing assertion" }, { status: 400 }); } + // Read the challenge from the cookie set during login/start + const expectedChallenge = request.cookies.get("login_challenge")?.value; + if (!expectedChallenge) { + return NextResponse.json({ error: "Challenge expired or missing. Please try logging in again." }, { status: 400 }); + } + + // Attach the challenge to the assertion for verification + // The browser assertion response does NOT include the challenge + assertion.challenge = expectedChallenge; + const result = await finishPasskeyLogin(request, assertion); if (result.verified) { const cookieStore = await cookies(); await setDashboardAuthCookie(cookieStore, request, { passkey: true }); - return NextResponse.json({ success: true }, { headers: { "Cache-Control": "no-store" } }); + + // Clear the challenge cookie + const response = NextResponse.json({ success: true }, { headers: { "Cache-Control": "no-store" } }); + response.cookies.delete("login_challenge", { path: "/api/auth/passkey/login" }); + return response; } return NextResponse.json({ error: "Authentication failed" }, { status: 401 }); diff --git a/src/app/api/auth/passkey/login/start/route.js b/src/app/api/auth/passkey/login/start/route.js index d821275539..59639c26df 100644 --- a/src/app/api/auth/passkey/login/start/route.js +++ b/src/app/api/auth/passkey/login/start/route.js @@ -11,7 +11,18 @@ export async function POST(request) { } const options = await startPasskeyLogin(request); - return NextResponse.json(options); + + // Store challenge in a cookie so login/finish can verify it + const response = NextResponse.json(options); + response.cookies.set("login_challenge", options.challenge, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/api/auth/passkey/login", + maxAge: 120, // 2 minutes + }); + + return response; } catch (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } diff --git a/src/app/api/auth/passkey/register/finish/route.js b/src/app/api/auth/passkey/register/finish/route.js index cc0a6b4f8e..3a464d3ae9 100644 --- a/src/app/api/auth/passkey/register/finish/route.js +++ b/src/app/api/auth/passkey/register/finish/route.js @@ -19,8 +19,23 @@ export async function POST(request) { return NextResponse.json({ error: "Missing credential" }, { status: 400 }); } + // Read the challenge from the cookie set during register/start + // The browser credential response does NOT include the challenge + const expectedChallenge = request.cookies.get("passkey_challenge")?.value; + if (!expectedChallenge) { + return NextResponse.json({ error: "Challenge expired or missing. Please try registering again." }, { status: 400 }); + } + + // Attach the challenge to the credential object for verification + credential.challenge = expectedChallenge; + const result = await finishPasskeyRegistration(request, credential, nickname); - return NextResponse.json(result); + + // Clear the challenge cookie + const response = NextResponse.json(result); + response.cookies.delete("passkey_challenge", { path: "/api/auth/passkey/register" }); + + return response; } catch (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } diff --git a/src/app/api/auth/passkey/register/start/route.js b/src/app/api/auth/passkey/register/start/route.js index ff9e755dc1..8200f46466 100644 --- a/src/app/api/auth/passkey/register/start/route.js +++ b/src/app/api/auth/passkey/register/start/route.js @@ -15,7 +15,18 @@ export async function POST(request) { } const options = await startPasskeyRegistration(request); - return NextResponse.json(options); + + // Store challenge in a cookie so register/finish can verify it + const response = NextResponse.json(options); + response.cookies.set("passkey_challenge", options.challenge, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/api/auth/passkey/register", + maxAge: 120, // 2 minutes + }); + + return response; } catch (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } diff --git a/src/app/login/page.js b/src/app/login/page.js index 20fb9b891a..50902d949c 100644 --- a/src/app/login/page.js +++ b/src/app/login/page.js @@ -145,7 +145,7 @@ export default function LoginPage() { // Browser API: prompt user for passkey const { startAuthentication } = await import("@/lib/auth/passkeyBrowser.js"); - const assertion = await startAuthentication({ responseJSON: options }); + const assertion = await startAuthentication({ optionsJSON: options }); // Finish authentication const finishRes = await fetch("/api/auth/passkey/login/finish", { diff --git a/src/app/masuk/MasukClient.js b/src/app/masuk/MasukClient.js index fac256865b..06a161f78b 100644 --- a/src/app/masuk/MasukClient.js +++ b/src/app/masuk/MasukClient.js @@ -80,7 +80,7 @@ export default function MasukClient({ initialAuth }) { const options = await startRes.json(); const { startAuthentication } = await import("@/lib/auth/passkeyBrowser.js"); - const assertion = await startAuthentication({ responseJSON: options }); + const assertion = await startAuthentication({ optionsJSON: options }); const finishRes = await fetch("/api/auth/passkey/login/finish", { method: "POST", From 47c59952164ce52619064b9cb34a351b1c9e1b55 Mon Sep 17 00:00:00 2001 From: mahdiwafy Date: Thu, 30 Jul 2026 11:15:06 +0700 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20remove=20remoteAuthMode=20setting?= =?UTF-8?q?=20=E2=80=94=20passkey=20is=20optional,=20never=20exclusive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(dashboard)/dashboard/profile/page.js | 49 ------------------- src/app/api/auth/status/route.js | 2 - src/app/login/page.js | 14 ++---- src/app/masuk/MasukClient.js | 7 +-- src/app/masuk/page.js | 3 +- src/lib/auth/passkeys.js | 2 +- src/lib/db/repos/settingsRepo.js | 1 - 7 files changed, 8 insertions(+), 70 deletions(-) diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 233603f0ca..253149c0eb 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -61,7 +61,6 @@ export default function ProfilePage() { const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyStatus, setPasskeyStatus] = useState({ type: "", message: "" }); const [passkeyNickname, setPasskeyNickname] = useState(""); - const [remoteAuthModeForm, setRemoteAuthModeForm] = useState("password"); /* eslint-disable react-hooks/set-state-in-effect -- Locale sync on mount and bootstrap fetch of /api/settings. setState is @@ -75,7 +74,6 @@ export default function ProfilePage() { .then((res) => res.json()) .then((data) => { setSettings(data); - setRemoteAuthModeForm(data?.remoteAuthMode || "password"); setOidcForm({ authMode: data?.authMode || "password", oidcIssuerUrl: data?.oidcIssuerUrl || "", @@ -315,18 +313,6 @@ export default function ProfilePage() { } }; - const handleRemoteAuthModeChange = async (mode) => { - setRemoteAuthModeForm(mode); - try { - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ remoteAuthMode: mode }), - }); - setSettings((prev) => ({ ...prev, remoteAuthMode: mode })); - } catch {} - }; - const updateFallbackStrategy = async (strategy) => { try { const res = await fetch("/api/settings", { @@ -867,41 +853,6 @@ export default function ProfilePage() {
- {/* Remote auth mode selector */} -
- -

- What login methods are available to non-localhost users. Localhost always has full access. -

-
- {[ - { value: "password", label: "Password only" }, - { value: "passkey", label: "Passkey only" }, - { value: "both", label: "Password + Passkey" }, - ].map((opt) => ( - - ))} -
- {passkeys.length === 0 && remoteAuthModeForm !== "password" && ( -

- Register at least one passkey before switching to passkey-only mode. -

- )} -
- -
{/* Register new passkey */}
diff --git a/src/app/api/auth/status/route.js b/src/app/api/auth/status/route.js index 52afd2486f..f8770f5f13 100644 --- a/src/app/api/auth/status/route.js +++ b/src/app/api/auth/status/route.js @@ -27,7 +27,6 @@ export async function GET(request) { oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC", hasPassword: !!settings.password, passkeysEnabled: !!settings.passkeysEnabled, - remoteAuthMode: settings.remoteAuthMode || "password", displayName, loginMethod, oidcName: oidcName || null, @@ -44,7 +43,6 @@ export async function GET(request) { oidcLoginLabel: "Sign in with OIDC", hasPassword: false, passkeysEnabled: false, - remoteAuthMode: "password", displayName: "Password user", loginMethod: "Password", oidcName: null, diff --git a/src/app/login/page.js b/src/app/login/page.js index 50902d949c..eb5b6a3934 100644 --- a/src/app/login/page.js +++ b/src/app/login/page.js @@ -18,7 +18,6 @@ export default function LoginPage() { const [newPassword, setNewPassword] = useState(""); const [isLocal, setIsLocal] = useState(false); const [passkeysEnabled, setPasskeysEnabled] = useState(false); - const [remoteAuthMode, setRemoteAuthMode] = useState("password"); // Countdown for rate-limit useEffect(() => { @@ -52,7 +51,6 @@ export default function LoginPage() { setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC"); setIsLocal(data.isLocal === true); setPasskeysEnabled(data.passkeysEnabled === true); - setRemoteAuthMode(data.remoteAuthMode || "password"); } else { // Safe fallback on non-OK response to avoid infinite loading state. setHasPassword(true); @@ -172,11 +170,9 @@ export default function LoginPage() { }; const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode); - const passkeyAvailable = passkeysEnabled && (isLocal || ["passkey", "both"].includes(remoteAuthMode)); - // On remote: if remoteAuthMode is "passkey" only, hide password - const passwordAvailable = isLocal - ? (authMode !== "oidc" || !oidcConfigured) - : (remoteAuthMode !== "passkey" && (authMode !== "oidc" || !oidcConfigured)); + const passkeyAvailable = passkeysEnabled; + // Password always available (remoteAuthMode removed — passkey is optional, never exclusive) + const passwordAvailable = authMode !== "oidc" || !oidcConfigured; // Show loading state while checking password if (hasPassword === null) { @@ -200,9 +196,7 @@ export default function LoginPage() {

{authMode === "oidc" && oidcConfigured ? "Sign in with your OIDC provider to access the dashboard" - : passkeyAvailable && !passwordAvailable - ? "Sign in with a passkey to access the dashboard" - : "Enter your password to access the dashboard"} + : "Enter your password or use a passkey to access the dashboard"}

diff --git a/src/app/masuk/MasukClient.js b/src/app/masuk/MasukClient.js index 06a161f78b..8ce961bbe2 100644 --- a/src/app/masuk/MasukClient.js +++ b/src/app/masuk/MasukClient.js @@ -28,7 +28,6 @@ export default function MasukClient({ initialAuth }) { const oidcLoginLabel = initialAuth?.oidcLoginLabel || "Masuk dengan OIDC"; const isLocal = initialAuth?.isLocal === true; const passkeysEnabled = initialAuth?.passkeysEnabled === true; - const remoteAuthMode = initialAuth?.remoteAuthMode || "password"; const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyError, setPasskeyError] = useState(""); const router = useRouter(); @@ -107,10 +106,8 @@ export default function MasukClient({ initialAuth }) { }; const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode); - const passkeyAvailable = passkeysEnabled && (isLocal || ["passkey", "both"].includes(remoteAuthMode)); - const passwordAvailable = isLocal - ? (authMode !== "oidc" || !oidcConfigured) - : (remoteAuthMode !== "passkey" && (authMode !== "oidc" || !oidcConfigured)); + const passkeyAvailable = passkeysEnabled; + const passwordAvailable = authMode !== "oidc" || !oidcConfigured; if (hasPassword === null) { return ( diff --git a/src/app/masuk/page.js b/src/app/masuk/page.js index 6d03eec9f5..52411d5a6d 100644 --- a/src/app/masuk/page.js +++ b/src/app/masuk/page.js @@ -7,7 +7,7 @@ import { isLocalRequest } from "@/dashboardGuard"; import MasukClient from "./MasukClient"; export default async function MasukPage() { - let initialAuth = { hasPassword: true, authMode: "password", oidcConfigured: false, oidcLoginLabel: "Masuk dengan OIDC", requireLogin: true, isLocal: false, passkeysEnabled: false, remoteAuthMode: "password" }; + let initialAuth = { hasPassword: true, authMode: "password", oidcConfigured: false, oidcLoginLabel: "Masuk dengan OIDC", requireLogin: true, isLocal: false, passkeysEnabled: false }; try { const settings = await getSettings(); const cookieStore = await cookies(); @@ -28,7 +28,6 @@ export default async function MasukPage() { isLoggedIn: !!session, isLocal: local, passkeysEnabled: !!settings.passkeysEnabled, - remoteAuthMode: settings.remoteAuthMode || "password", }; } catch {} return ; diff --git a/src/lib/auth/passkeys.js b/src/lib/auth/passkeys.js index 6c67b022fd..c04d9edc16 100644 --- a/src/lib/auth/passkeys.js +++ b/src/lib/auth/passkeys.js @@ -174,6 +174,6 @@ export async function removePasskey(id) { // Auto-disable passkeys when the last one is removed const count = (await getPasskeys()).length; if (count === 0) { - await updateSettings({ passkeysEnabled: false, remoteAuthMode: "password" }); + await updateSettings({ passkeysEnabled: false }); } } diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index c4d78ff0f0..61b5aabdc6 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -21,7 +21,6 @@ const DEFAULT_SETTINGS = { allowRemoteNoApiKey: false, tunnelDashboardAccess: true, authMode: "password", - remoteAuthMode: "password", passkeysEnabled: false, oidcIssuerUrl: "", oidcClientId: "",