Add passkey login for protected UI (#845)

* feat(auth): add passkey login for protected UI

* fix: polish passkey setup and correct WebAuthn user IDs

* feat: improve passkey login management

* build: align passkey deps with upstream main

* refactor: move ui auth out of opencode module

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Dave Otero
2026-04-11 22:36:02 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 37cf7d9c79
commit 75a10ea66c
15 changed files with 1640 additions and 53 deletions
+47
View File
@@ -146,6 +146,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13",
"beautiful-mermaid": "^1.1.3",
@@ -253,6 +254,8 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"adm-zip": "^0.5.16",
"bun-pty": "^0.4.5",
@@ -709,6 +712,8 @@
"@heroui/use-data-scroll-overflow": ["@heroui/use-data-scroll-overflow@2.2.13", "", { "dependencies": { "@heroui/shared-utils": "2.1.12" }, "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } }, "sha512-zboLXO1pgYdzMUahDcVt5jf+l1jAQ/D9dFqr7AxWLfn6tn7/EgY0f6xIrgWDgJnM0U3hKxVeY13pAeB4AFTqTw=="],
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -747,6 +752,8 @@
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
"@lezer/cpp": ["@lezer/cpp@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw=="],
@@ -821,6 +828,30 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.17", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA=="],
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
"@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="],
"@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="],
"@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="],
"@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="],
"@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="],
"@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="],
"@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="],
"@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="],
"@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "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" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -1027,6 +1058,10 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "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" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="],
"@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="],
@@ -1241,6 +1276,8 @@
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@@ -2213,6 +2250,10 @@
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
@@ -2255,6 +2296,8 @@
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="],
@@ -2493,6 +2536,8 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
@@ -2861,6 +2906,8 @@
"tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+3 -2
View File
@@ -52,6 +52,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13",
"beautiful-mermaid": "^1.1.3",
@@ -66,8 +67,9 @@
"heic2any": "^0.0.4",
"html-to-image": "^1.11.13",
"http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24",
"marked": "^17.0.3",
"morphdom": "^2.7.7",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4",
@@ -76,7 +78,6 @@
"react-syntax-highlighter": "^15.6.6",
"remend": "^1.2.1",
"simple-git": "^3.28.0",
"marked": "^17.0.3",
"sonner": "^2.0.7",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
@@ -1,14 +1,27 @@
import React from 'react';
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import {
authenticateWithPasskey,
cancelPasskeyCeremony,
defaultPasskeyStatus,
fetchPasskeyStatus,
isPasskeyCeremonyAbort,
type PasskeyStatus,
registerCurrentDevicePasskey,
} from '@/lib/passkeys';
const STATUS_CHECK_ENDPOINT = '/auth/session';
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
const fetchSessionStatus = async (): Promise<Response> => {
console.log('[Frontend Auth] Checking session status...');
@@ -23,7 +36,14 @@ const fetchSessionStatus = async (): Promise<Response> => {
return response;
};
const submitPassword = async (password: string): Promise<Response> => {
const readStoredTrustDevice = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem(TRUST_DEVICE_STORAGE_KEY) === 'true';
};
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
console.log('[Frontend Auth] Submitting password...');
const response = await fetch(STATUS_CHECK_ENDPOINT, {
method: 'POST',
@@ -32,7 +52,7 @@ const submitPassword = async (password: string): Promise<Response> => {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ password }),
body: JSON.stringify({ password, trustDevice }),
});
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
return response;
@@ -115,9 +135,66 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
const [errorMessage, setErrorMessage] = React.useState('');
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
const [isTunnelLocked, setIsTunnelLocked] = React.useState(false);
const [passkeyStatus, setPasskeyStatus] = React.useState<PasskeyStatus>(defaultPasskeyStatus);
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
const [isPasskeyBusy, setIsPasskeyBusy] = React.useState(false);
const [trustDevice, setTrustDevice] = React.useState<boolean>(() => readStoredTrustDevice());
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const hasResyncedRef = React.useRef(skipAuth);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(TRUST_DEVICE_STORAGE_KEY, trustDevice ? 'true' : 'false');
}, [trustDevice]);
const refreshPasskeyStatus = React.useCallback(async () => {
if (skipAuth) {
return defaultPasskeyStatus;
}
try {
const nextStatus = await fetchPasskeyStatus();
setPasskeyStatus(nextStatus);
return nextStatus;
} catch {
setPasskeyStatus(defaultPasskeyStatus);
return defaultPasskeyStatus;
}
}, [skipAuth]);
React.useEffect(() => {
let cancelled = false;
if (skipAuth) {
return;
}
void (async () => {
try {
if (!window.isSecureContext || !browserSupportsWebAuthn()) {
if (!cancelled) {
setSupportsPasskeys(false);
}
return;
}
if (!cancelled) {
setSupportsPasskeys(true);
}
} catch {
if (!cancelled) {
setSupportsPasskeys(false);
}
}
})();
return () => {
cancelled = true;
};
}, [skipAuth]);
const checkStatus = React.useCallback(async () => {
if (skipAuth) {
console.log('[Frontend Auth] VSCode runtime, skipping auth');
@@ -125,16 +202,12 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
return;
}
// 检查 cookie 是否存在
const cookies = document.cookie;
const hasAccessToken = cookies.includes('oc_ui_session=');
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
console.log('[Frontend Auth] Cookies check - access:', hasAccessToken, 'refresh:', hasRefreshToken);
console.log('[Frontend Auth] All cookies:', cookies.split(';').map(c => c.trim().split('=')[0]));
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
try {
const response = await fetchSessionStatus();
const [response, latestPasskeyStatus] = await Promise.all([
fetchSessionStatus(),
refreshPasskeyStatus(),
]);
const responseText = await response.text();
console.log('[Frontend Auth] Raw response:', response.status, responseText);
@@ -158,6 +231,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
console.warn('[Frontend Auth] Debug info:', data.debug);
}
setIsTunnelLocked(data.tunnelLocked === true);
setPasskeyStatus(latestPasskeyStatus);
setState('locked');
setRetryAfter(undefined);
return;
@@ -182,7 +256,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
setState('error');
setIsTunnelLocked(false);
}
}, [skipAuth]);
}, [refreshPasskeyStatus, skipAuth]);
React.useEffect(() => {
if (skipAuth) {
@@ -220,6 +294,28 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await handlePasswordUnlock(false);
};
const registerPasskeyForCurrentSession = React.useCallback(async () => {
setActivePasskeyAction('register');
setIsPasskeyBusy(true);
try {
await registerCurrentDevicePasskey();
} finally {
setActivePasskeyAction(null);
setIsPasskeyBusy(false);
}
await refreshPasskeyStatus();
}, [refreshPasskeyStatus]);
const cancelActivePasskey = React.useCallback(() => {
cancelPasskeyCeremony();
setActivePasskeyAction(null);
setIsPasskeyBusy(false);
}, []);
const handlePasswordUnlock = React.useCallback(async (enrollPasskey: boolean) => {
if (isTunnelLocked) {
return;
}
@@ -227,21 +323,36 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
return;
}
if (isPasskeyBusy) {
cancelActivePasskey();
}
setIsSubmitting(true);
setErrorMessage('');
try {
const response = await submitPassword(password);
const response = await submitPassword(password, trustDevice);
if (response.ok) {
console.log('[Frontend Auth] Login successful');
// 检查登录后 cookie 是否被设置
const cookies = document.cookie;
const hasAccessToken = cookies.includes('oc_ui_session=');
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
setPassword('');
setIsTunnelLocked(false);
if (enrollPasskey && supportsPasskeys) {
try {
await registerPasskeyForCurrentSession();
toast.success('Passkey added');
setState('authenticated');
return;
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
toast.message('Passkey setup canceled');
} else {
const message = error instanceof Error ? error.message : 'Passkey setup failed.';
toast.error(message);
}
setState('authenticated');
return;
}
}
setState('authenticated');
return;
}
@@ -275,7 +386,75 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
} finally {
setIsSubmitting(false);
}
};
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, trustDevice]);
const handlePasskeyUnlock = React.useCallback(async () => {
if (isSubmitting || !supportsPasskeys) {
return;
}
if (isPasskeyBusy) {
cancelActivePasskey();
return;
}
setIsPasskeyBusy(true);
setActivePasskeyAction('auth');
setErrorMessage('');
try {
await authenticateWithPasskey(trustDevice);
setPassword('');
setState('authenticated');
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
setErrorMessage('');
} else {
const message = error instanceof Error ? error.message : 'Passkey sign-in was cancelled.';
setErrorMessage(message);
}
} finally {
setActivePasskeyAction(null);
setIsPasskeyBusy(false);
}
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, trustDevice]);
const handlePasskeySetupOnly = React.useCallback(async () => {
if (isSubmitting || isTunnelLocked || !supportsPasskeys) {
return;
}
if (isPasskeyBusy) {
cancelActivePasskey();
return;
}
if (state !== 'authenticated') {
if (!password) {
setErrorMessage('Enter your password to add a passkey.');
return;
}
await handlePasswordUnlock(true);
return;
}
setErrorMessage('');
try {
await registerPasskeyForCurrentSession();
toast.success('Passkey added');
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
toast.message('Passkey setup canceled');
return;
}
const message = error instanceof Error ? error.message : 'Passkey setup failed.';
toast.error(message);
}
}, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys]);
const canOfferPasskeySetup = supportsPasskeys && passkeyStatus.enabled;
const canUsePasskey = canOfferPasskeySetup && passkeyStatus.hasPasskeys;
if (state === 'pending') {
return <LoadingScreen />;
@@ -306,6 +485,22 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
{!isTunnelLocked && (
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
{canUsePasskey && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => void handlePasskeyUnlock()}
disabled={isSubmitting || (isPasskeyBusy && activePasskeyAction !== 'auth')}
>
{isPasskeyBusy ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiLockUnlockLine className="h-4 w-4" />
)}
<span>{isPasskeyBusy && activePasskeyAction === 'auth' ? 'Cancel passkey' : 'Use passkey'}</span>
</Button>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
@@ -341,6 +536,43 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
)}
</Button>
</div>
{canOfferPasskeySetup ? (
<div className="flex items-center justify-between pt-1">
<label className="flex items-center gap-2 text-center typography-micro text-muted-foreground">
<Checkbox
checked={trustDevice}
onChange={setTrustDevice}
disabled={isSubmitting}
ariaLabel="Trust this device"
className="size-4"
iconClassName="size-4"
/>
<span>Trust this device</span>
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => void handlePasskeySetupOnly()}
disabled={isSubmitting}
>
{isPasskeyBusy && activePasskeyAction === 'register' ? 'Cancel passkey setup' : 'Add passkey'}
</Button>
</div>
) : (
<label className="flex items-center justify-center gap-2 pt-1 text-center typography-micro text-muted-foreground">
<Checkbox
checked={trustDevice}
onChange={setTrustDevice}
disabled={isSubmitting}
ariaLabel="Trust this device"
className="size-4"
iconClassName="size-4"
/>
<span>Trust this device</span>
</label>
)}
{errorMessage && (
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
{errorMessage}
@@ -2,6 +2,7 @@ import React from 'react';
import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { PasskeySettings } from './PasskeySettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { NotificationSettings } from './NotificationSettings';
@@ -46,6 +47,9 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
<div className="border-t border-border/40 pt-6">
<PasskeySettings />
</div>
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
@@ -133,6 +137,9 @@ const SessionsSectionContent: React.FC = () => {
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
<div className="border-t border-border/40 pt-6">
<PasskeySettings />
</div>
</div>
);
};
@@ -0,0 +1,255 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import {
cancelPasskeyCeremony,
defaultPasskeyStatus,
fetchPasskeyStatus,
fetchStoredPasskeys,
getPasskeySupportState,
isPasskeyCeremonyAbort,
registerCurrentDevicePasskey,
resetAllAuth,
revokeStoredPasskey,
type PasskeyStatus,
type StoredPasskey,
} from '@/lib/passkeys';
const formatTimestamp = (timestamp: number | null) => {
if (!timestamp || !Number.isFinite(timestamp)) {
return 'Never used';
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(timestamp);
};
export const PasskeySettings: React.FC = () => {
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const [isRegistering, setIsRegistering] = React.useState(false);
const [revokingId, setRevokingId] = React.useState<string | null>(null);
const [isResetting, setIsResetting] = React.useState(false);
const [passkeys, setPasskeys] = React.useState<StoredPasskey[]>([]);
const [status, setStatus] = React.useState<PasskeyStatus>(defaultPasskeyStatus);
const [errorMessage, setErrorMessage] = React.useState('');
const supportState = React.useMemo(() => getPasskeySupportState(), []);
const loadPasskeys = React.useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const nextPasskeys = await fetchStoredPasskeys();
setPasskeys(nextPasskeys);
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not load passkeys.';
setErrorMessage(message);
} finally {
setIsLoading(false);
}
}, []);
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
if (!supportState.supported) {
if (!cancelled) {
setSupportsPasskeys(false);
setIsLoading(false);
}
return;
}
if (!cancelled) {
setSupportsPasskeys(true);
}
} catch {
if (!cancelled) {
setSupportsPasskeys(false);
}
}
if (!cancelled) {
const nextStatus = await fetchPasskeyStatus();
setStatus(nextStatus);
if (!nextStatus.enabled) {
setPasskeys([]);
setIsLoading(false);
return;
}
await loadPasskeys();
}
})();
return () => {
cancelled = true;
cancelPasskeyCeremony();
};
}, [loadPasskeys, supportState.supported]);
const handleRegisterPasskey = React.useCallback(async () => {
if (!status.enabled) {
const message = 'Enable the UI password lock before adding passkeys.';
setErrorMessage(message);
toast.message(message);
return;
}
if (!supportsPasskeys) {
setErrorMessage(supportState.reason);
toast.message(supportState.reason);
return;
}
if (isRegistering) {
cancelPasskeyCeremony();
setIsRegistering(false);
return;
}
setErrorMessage('');
setIsRegistering(true);
try {
await registerCurrentDevicePasskey();
setStatus(await fetchPasskeyStatus());
await loadPasskeys();
toast.success('Passkey added');
} catch (error) {
if (isPasskeyCeremonyAbort(error)) {
toast.message('Passkey setup canceled');
return;
}
const message = error instanceof Error ? error.message : 'Could not add passkey.';
setErrorMessage(message);
toast.error(message);
} finally {
setIsRegistering(false);
}
}, [isRegistering, loadPasskeys, status.enabled, supportState.reason, supportsPasskeys]);
const handleRevokePasskey = React.useCallback(async (id: string) => {
setRevokingId(id);
setErrorMessage('');
try {
await revokeStoredPasskey(id);
setStatus(await fetchPasskeyStatus());
await loadPasskeys();
toast.success('Passkey removed');
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not remove passkey.';
setErrorMessage(message);
toast.error(message);
} finally {
setRevokingId(null);
}
}, [loadPasskeys]);
const handleResetAllAuth = React.useCallback(async () => {
setIsResetting(true);
setErrorMessage('');
try {
await resetAllAuth();
window.location.reload();
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not clear saved authentication.';
setErrorMessage(message);
toast.error(message);
setIsResetting(false);
}
}, []);
return (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">Passkeys</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-2">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Current device</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<Button
type="button"
variant={isRegistering ? 'secondary' : 'outline'}
size="xs"
onClick={() => void handleRegisterPasskey()}
disabled={isLoading || isResetting}
className="!font-normal"
>
{isRegistering ? 'Cancel passkey setup' : 'Add passkey'}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleResetAllAuth()}
disabled={isLoading || isRegistering || isResetting}
className="!font-normal text-muted-foreground hover:text-foreground"
>
{isResetting ? 'Signing out…' : 'Sign out everywhere'}
</Button>
</div>
</div>
{!status.enabled && (
<p className="typography-meta text-muted-foreground">
Passkeys are available only when the UI password lock is enabled.
</p>
)}
{status.enabled && !supportsPasskeys && (
<p className="typography-meta text-muted-foreground">
{supportState.reason}
</p>
)}
{isLoading ? (
<p className="typography-meta text-muted-foreground">Loading passkeys</p>
) : passkeys.length === 0 ? (
<p className="typography-meta text-muted-foreground">No passkeys saved for this host yet.</p>
) : (
<div className="space-y-1 pt-1">
{passkeys.map((passkey) => (
<div key={passkey.id} className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground truncate">{passkey.label}</span>
</div>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground truncate">
{passkey.lastUsedAt ? `Last used ${formatTimestamp(passkey.lastUsedAt)}` : `Added ${formatTimestamp(passkey.createdAt)}`}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void handleRevokePasskey(passkey.id)}
disabled={revokingId === passkey.id}
className="!font-normal text-muted-foreground hover:text-foreground"
>
{revokingId === passkey.id ? 'Removing…' : 'Remove'}
</Button>
</div>
</div>
))}
</div>
)}
</section>
{errorMessage && (
<div className="mt-1 px-2 py-1.5">
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
</div>
)}
</div>
);
};
+200
View File
@@ -0,0 +1,200 @@
import {
startAuthentication,
startRegistration,
WebAuthnAbortService,
WebAuthnError,
} from '@simplewebauthn/browser';
const PASSKEY_AUTH_OPTIONS_ENDPOINT = '/auth/passkey/authenticate/options';
const PASSKEY_AUTH_VERIFY_ENDPOINT = '/auth/passkey/authenticate/verify';
const PASSKEY_REGISTER_OPTIONS_ENDPOINT = '/auth/passkey/register/options';
const PASSKEY_REGISTER_VERIFY_ENDPOINT = '/auth/passkey/register/verify';
const PASSKEY_LIST_ENDPOINT = '/api/passkeys';
const PASSKEY_STATUS_ENDPOINT = '/auth/passkey/status';
const AUTH_RESET_ENDPOINT = '/api/auth/reset';
export type PasskeyStatus = {
enabled: boolean;
hasPasskeys: boolean;
passkeyCount: number;
rpID: string | null;
};
export type StoredPasskey = {
id: string;
label: string;
createdAt: number;
lastUsedAt: number | null;
deviceType: string;
backedUp: boolean;
};
export const defaultPasskeyStatus: PasskeyStatus = {
enabled: false,
hasPasskeys: false,
passkeyCount: 0,
rpID: null,
};
const postJson = async (url: string, body?: unknown): Promise<Response> => fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
export const getPasskeyErrorMessage = async (response: Response, fallback: string): Promise<string> => {
try {
const payload = await response.json();
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
return payload.error;
}
} catch {
// Ignore malformed error payloads and fall back to the provided message.
}
return fallback;
};
export const isPasskeyCeremonyAbort = (error: unknown): boolean => (
error instanceof WebAuthnError && error.code === 'ERROR_CEREMONY_ABORTED'
);
export const cancelPasskeyCeremony = () => {
WebAuthnAbortService.cancelCeremony();
};
export const getPasskeySupportState = () => {
if (typeof window === 'undefined') {
return { supported: false, reason: 'Passkeys are unavailable outside the browser.' };
}
if (!window.isSecureContext) {
return { supported: false, reason: 'Passkeys require HTTPS or localhost.' };
}
return { supported: true, reason: '' };
};
export const registerCurrentDevicePasskey = async () => {
const support = getPasskeySupportState();
if (!support.supported) {
throw new Error(support.reason);
}
const label = typeof navigator.userAgent === 'string' && navigator.userAgent.trim()
? navigator.userAgent
: 'This device';
const optionsResponse = await postJson(PASSKEY_REGISTER_OPTIONS_ENDPOINT, { label });
if (!optionsResponse.ok) {
throw new Error(await getPasskeyErrorMessage(optionsResponse, 'Could not start passkey setup.'));
}
const { requestId, optionsJSON } = await optionsResponse.json();
const registrationResponse = await startRegistration({ optionsJSON });
const verifyResponse = await postJson(PASSKEY_REGISTER_VERIFY_ENDPOINT, {
requestId,
response: registrationResponse,
});
if (!verifyResponse.ok) {
throw new Error(await getPasskeyErrorMessage(verifyResponse, 'Could not finish passkey setup.'));
}
return verifyResponse.json().catch(() => null);
};
export const authenticateWithPasskey = async (trustDevice: boolean) => {
const support = getPasskeySupportState();
if (!support.supported) {
throw new Error(support.reason);
}
const optionsResponse = await postJson(PASSKEY_AUTH_OPTIONS_ENDPOINT);
if (!optionsResponse.ok) {
throw new Error(await getPasskeyErrorMessage(optionsResponse, 'Passkey sign-in is not available right now.'));
}
const { requestId, optionsJSON } = await optionsResponse.json();
const authResponse = await startAuthentication({ optionsJSON });
const verifyResponse = await postJson(PASSKEY_AUTH_VERIFY_ENDPOINT, {
requestId,
response: authResponse,
trustDevice,
});
if (!verifyResponse.ok) {
throw new Error(await getPasskeyErrorMessage(verifyResponse, 'Passkey sign-in failed.'));
}
return verifyResponse.json().catch(() => null);
};
export const fetchPasskeyStatus = async (): Promise<PasskeyStatus> => {
const response = await fetch(PASSKEY_STATUS_ENDPOINT, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
return defaultPasskeyStatus;
}
const payload = await response.json().catch(() => null);
return {
enabled: payload?.enabled === true,
hasPasskeys: payload?.hasPasskeys === true,
passkeyCount: typeof payload?.passkeyCount === 'number' ? payload.passkeyCount : 0,
rpID: typeof payload?.rpID === 'string' && payload.rpID ? payload.rpID : null,
};
};
export const fetchStoredPasskeys = async (): Promise<StoredPasskey[]> => {
const response = await fetch(PASSKEY_LIST_ENDPOINT, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(await getPasskeyErrorMessage(response, 'Could not load passkeys.'));
}
const payload = await response.json().catch(() => null);
return Array.isArray(payload?.passkeys) ? payload.passkeys : [];
};
export const revokeStoredPasskey = async (id: string) => {
const response = await fetch(`${PASSKEY_LIST_ENDPOINT}/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(await getPasskeyErrorMessage(response, 'Could not remove passkey.'));
}
return response.json().catch(() => null);
};
export const resetAllAuth = async () => {
const response = await postJson(AUTH_RESET_ENDPOINT);
if (!response.ok) {
throw new Error(await getPasskeyErrorMessage(response, 'Could not clear saved authentication.'));
}
return response.json().catch(() => null);
};
+2
View File
@@ -40,6 +40,8 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"adm-zip": "^0.5.16",
"bun-pty": "^0.4.5",
+1 -1
View File
@@ -7,7 +7,7 @@ import net from 'net';
import { fileURLToPath } from 'url';
import os from 'os';
import crypto from 'crypto';
import { createUiAuth } from './lib/opencode/ui-auth.js';
import { createUiAuth } from './lib/ui-auth/ui-auth.js';
import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js';
import { createTunnelProviderRegistry } from './lib/tunnels/registry.js';
@@ -1,7 +1,7 @@
# OpenCode Module Documentation
## Purpose
This module provides OpenCode server integration utilities for the web server runtime, including configuration management, provider authentication, and UI authentication with rate limiting.
This module provides OpenCode server integration utilities for the web server runtime, including configuration management and provider authentication.
## Entrypoints and structure
- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
@@ -40,7 +40,8 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events.
- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout.
- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers.
- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting.
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI session authentication runtime (outside OpenCode module).
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: UI passkey storage and WebAuthn registration/authentication helpers (outside OpenCode module).
## Public exports (auth.js)
- `readAuthFile()`: Reads and parses `~/.local/share/opencode/auth.json`.
@@ -67,15 +68,6 @@ This module provides OpenCode server integration utilities for the web server ru
- `resolveSkillSearchDirectories(workingDirectory)`: Returns skill search path order (config, project, home, custom).
- `listSkillSupportingFiles(skillDir)`, `readSkillSupportingFile(skillDir, relativePath)`, `writeSkillSupportingFile(skillDir, relativePath, content)`, `deleteSkillSupportingFile(skillDir, relativePath)`: Skill supporting file management.
## Public exports (ui-auth.js)
- `createUiAuth({ password, cookieName, sessionTtlMs })`: Creates UI auth instance with methods:
- `enabled`: Boolean indicating if auth is configured.
- `requireAuth(req, res, next)`: Express middleware to enforce authentication.
- `handleSessionStatus(req, res)`: Returns authentication status.
- `handleSessionCreate(req, res)`: Handles login with rate limiting.
- `ensureSessionToken(req, res)`: Returns or creates session token.
- `dispose()`: Cleans up timers and state.
## Public exports (routes.js)
- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime:
- `GET /api/config/settings`
@@ -230,11 +222,19 @@ This module provides OpenCode server integration utilities for the web server ru
- `GET /health`
- `POST /api/system/shutdown`
- `GET /api/system/info`
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
- `GET /auth/session`
- `POST /auth/session`
- `GET /connect`
- `app.use('/api', ...)` auth/tunnel guard
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
- `GET /auth/session`
- `POST /auth/session`
- `GET /auth/passkey/status`
- `POST /auth/passkey/authenticate/options`
- `POST /auth/passkey/authenticate/verify`
- `POST /auth/passkey/register/options`
- `POST /auth/passkey/register/verify`
- `GET /api/passkeys`
- `DELETE /api/passkeys/:id`
- `POST /api/auth/reset`
- `GET /connect`
- `app.use('/api', ...)` auth/tunnel guard
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
- `GET /api/config/themes`
- `POST /api/config/reload`
+4 -1
View File
@@ -60,7 +60,10 @@ export const createBootstrapRuntime = (dependencies) => {
registerCommonRequestMiddleware(app, { express });
const uiAuthController = createUiAuth({ password: uiPassword });
const uiAuthController = createUiAuth({
password: uiPassword,
readSettingsFromDiskMigrated,
});
if (uiAuthController.enabled) {
console.log('UI password protection enabled for browser sessions');
}
@@ -67,6 +67,100 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return uiAuthController.handleSessionCreate(req, res);
});
app.get('/auth/passkey/status', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null, tunnelLocked: true });
}
return uiAuthController.handlePasskeyStatus(req, res);
});
app.post('/auth/passkey/authenticate/options', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
}
return uiAuthController.handlePasskeyAuthenticationOptions(req, res);
});
app.post('/auth/passkey/authenticate/verify', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
}
return uiAuthController.handlePasskeyAuthenticationVerify(req, res);
});
app.post('/auth/passkey/register/options', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyRegistrationOptions(req, res);
});
} catch (error) {
next(error);
}
});
app.post('/auth/passkey/register/verify', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyRegistrationVerify(req, res);
});
} catch (error) {
next(error);
}
});
app.get('/api/passkeys', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyList(req, res);
});
} catch (error) {
next(error);
}
});
app.delete('/api/passkeys/:id', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyRevoke(req, res);
});
} catch (error) {
next(error);
}
});
app.post('/api/auth/reset', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Global sign-out is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handleResetAuth(req, res);
});
} catch (error) {
next(error);
}
});
app.get('/connect', async (req, res) => {
try {
const token = typeof req.query?.t === 'string' ? req.query.t : '';
+1 -1
View File
@@ -55,7 +55,7 @@ export {
OPENCODE_DATA_DIR,
} from './auth.js';
export { createUiAuth } from './ui-auth.js';
export { createUiAuth } from '../ui-auth/ui-auth.js';
export {
listMcpConfigs,
@@ -0,0 +1,38 @@
# UI Auth Module Documentation
## Purpose
This module owns OpenChamber UI authentication for browser access, including password session auth, WebAuthn passkeys, and trusted-device session handling.
## Entrypoints and structure
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI auth controller runtime, cookie/session issuance, rate limiting, and auth route handlers.
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: passkey store and WebAuthn registration/authentication verification helpers.
## Public exports (ui-auth.js)
- `createUiAuth({ password, cookieName, sessionTtlMs, readSettingsFromDiskMigrated })`: creates UI auth controller with methods:
- `enabled`
- `requireAuth(req, res, next)`
- `handleSessionStatus(req, res)`
- `handleSessionCreate(req, res)`
- `handlePasskeyStatus(req, res)`
- `handlePasskeyRegistrationOptions(req, res)`
- `handlePasskeyRegistrationVerify(req, res)`
- `handlePasskeyAuthenticationOptions(req, res)`
- `handlePasskeyAuthenticationVerify(req, res)`
- `handlePasskeyList(req, res)`
- `handlePasskeyRevoke(req, res)`
- `handleResetAuth(req, res)`
- `ensureSessionToken(req, res)`
- `dispose()`
## Public exports (ui-passkeys.js)
- `createUiPasskeys({ passwordBinding, readSettingsFromDiskMigrated, storeFile, rpName, challengeTtlMs })`: creates passkey runtime with methods:
- `enabled`
- `getStatus(req)`
- `listPasskeys(req)`
- `revokePasskey(req, passkeyId)`
- `clearAllPasskeys()`
- `beginRegistration(req, { label })`
- `finishRegistration(payload)`
- `beginAuthentication(req)`
- `finishAuthentication(payload)`
- `dispose()`
@@ -3,9 +3,11 @@ import { SignJWT, jwtVerify } from 'jose';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { createUiPasskeys } from './ui-passkeys.js';
const SESSION_COOKIE_NAME = 'oc_ui_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const TRUSTED_DEVICE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
@@ -232,7 +234,11 @@ const parseCookies = (cookieHeader) => {
return acc;
}
const value = rest.join('=').trim();
acc[key] = decodeURIComponent(value || '');
try {
acc[key] = decodeURIComponent(value || '');
} catch {
acc[key] = value || '';
}
return acc;
}, {});
};
@@ -274,6 +280,8 @@ const normalizePassword = (candidate) => {
return candidate.normalize().trim();
};
const isTrustedDeviceRequest = (value) => value === true;
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
@@ -305,17 +313,30 @@ function getOrCreateJwtSecret() {
return new TextEncoder().encode(secret);
}
function persistJwtSecret(secret) {
if (process.env.OPENCODE_JWT_SECRET) {
const error = new Error('Global sign-out is unavailable while OPENCODE_JWT_SECRET is set');
error.statusCode = 400;
throw error;
}
fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true });
fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
return new TextEncoder().encode(secret);
}
export const createUiAuth = ({
password,
cookieName = SESSION_COOKIE_NAME,
sessionTtlMs = SESSION_TTL_MS,
readSettingsFromDiskMigrated,
} = {}) => {
const normalizedPassword = normalizePassword(password);
if (!normalizedPassword) {
const setSessionCookie = (req, res, token) => {
const setSessionCookie = (req, res, token, ttlMs = sessionTtlMs) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
const maxAgeSeconds = Math.floor(ttlMs / 1000);
const header = buildCookie({
name: cookieName,
value: encodeURIComponent(token),
@@ -331,7 +352,7 @@ export const createUiAuth = ({
return cookies[cookieName];
}
const token = crypto.randomBytes(32).toString('base64url');
setSessionCookie(req, res, token);
setSessionCookie(req, res, token, sessionTtlMs);
return token;
};
@@ -344,6 +365,30 @@ export const createUiAuth = ({
handleSessionCreate: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyStatus: (_req, res) => {
res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null });
},
handlePasskeyRegistrationOptions: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyRegistrationVerify: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyAuthenticationOptions: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyAuthenticationVerify: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyList: (_req, res) => {
res.json({ passkeys: [] });
},
handlePasskeyRevoke: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handleResetAuth: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
ensureSessionToken,
dispose: () => {
@@ -353,7 +398,28 @@ export const createUiAuth = ({
const salt = crypto.randomBytes(16);
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
const JWT_SECRET = getOrCreateJwtSecret();
let jwtSecret = getOrCreateJwtSecret();
let passwordBinding = crypto.createHmac('sha256', jwtSecret).update(normalizedPassword).digest('hex');
const resolveSessionTtlMs = (trustDevice) => (trustDevice ? TRUSTED_DEVICE_SESSION_TTL_MS : sessionTtlMs);
let passkeyController = createUiPasskeys({
passwordBinding,
readSettingsFromDiskMigrated,
});
const rebuildPasskeyController = () => {
passkeyController.dispose();
passwordBinding = crypto.createHmac('sha256', jwtSecret).update(normalizedPassword).digest('hex');
passkeyController = createUiPasskeys({
passwordBinding,
readSettingsFromDiskMigrated,
});
};
const rotateJwtSecret = () => {
const nextSecret = crypto.randomBytes(32).toString('hex');
jwtSecret = persistJwtSecret(nextSecret);
rebuildPasskeyController();
};
const getTokenFromRequest = (req) => {
const cookies = parseCookies(req.headers.cookie);
@@ -363,9 +429,9 @@ export const createUiAuth = ({
return null;
};
const setSessionCookie = (req, res, token) => {
const setSessionCookie = (req, res, token, ttlMs) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
const maxAgeSeconds = Math.floor(ttlMs / 1000);
const header = buildCookie({
name: cookieName,
value: encodeURIComponent(token),
@@ -407,20 +473,21 @@ export const createUiAuth = ({
return false;
}
try {
await jwtVerify(token, JWT_SECRET);
await jwtVerify(token, jwtSecret);
return true;
} catch {
return false;
}
};
const issueSession = async (req, res) => {
const issueSession = async (req, res, { trustDevice = false } = {}) => {
const ttlMs = resolveSessionTtlMs(trustDevice);
const token = await new SignJWT({ type: 'ui-session' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(sessionTtlMs / 1000 + 's')
.sign(JWT_SECRET);
setSessionCookie(req, res, token);
.setExpirationTime(ttlMs / 1000 + 's')
.sign(jwtSecret);
setSessionCookie(req, res, token, ttlMs);
return token;
};
@@ -484,16 +551,104 @@ export const createUiAuth = ({
await clearRateLimit(req);
await issueSession(req, res);
await issueSession(req, res, {
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
});
res.json({ authenticated: true });
};
const respondPasskeyError = (res, error) => {
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400;
res.status(statusCode).json({ error: error?.message || 'Passkey request failed' });
};
const handlePasskeyStatus = (req, res) => {
try {
res.json(passkeyController.getStatus(req));
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyRegistrationOptions = async (req, res) => {
try {
const label = typeof req.body?.label === 'string' ? req.body.label : '';
const options = await passkeyController.beginRegistration(req, { label });
res.json(options);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyRegistrationVerify = async (req, res) => {
try {
const result = await passkeyController.finishRegistration(req.body);
res.json(result);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyAuthenticationOptions = async (req, res) => {
try {
const options = await passkeyController.beginAuthentication(req);
res.json(options);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyAuthenticationVerify = async (req, res) => {
try {
await passkeyController.finishAuthentication(req.body);
await issueSession(req, res, {
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
});
res.json({ authenticated: true });
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyList = (req, res) => {
try {
res.json({ passkeys: passkeyController.listPasskeys(req) });
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyRevoke = (req, res) => {
try {
const result = passkeyController.revokePasskey(req, req.params?.id);
res.json(result);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handleResetAuth = (req, res) => {
try {
const passkeyResult = passkeyController.clearAllPasskeys();
rotateJwtSecret();
clearSessionCookie(req, res);
res.json({
cleared: true,
clearedPasskeys: passkeyResult.clearedCount,
signedOutEverywhere: true,
});
} catch (error) {
respondPasskeyError(res, error);
}
};
const dispose = () => {
loginRateLimiter.clear();
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
passkeyController.dispose();
};
return {
@@ -501,6 +656,14 @@ export const createUiAuth = ({
requireAuth,
handleSessionStatus,
handleSessionCreate,
handlePasskeyStatus,
handlePasskeyRegistrationOptions,
handlePasskeyRegistrationVerify,
handlePasskeyAuthenticationOptions,
handlePasskeyAuthenticationVerify,
handlePasskeyList,
handlePasskeyRevoke,
handleResetAuth,
ensureSessionToken: async (req, _res) => {
const token = getTokenFromRequest(req);
return (await isSessionValid(token)) ? token : null;
@@ -0,0 +1,545 @@
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
const DEFAULT_STORE_VERSION = 1;
const DEFAULT_CHALLENGE_TTL_MS = 5 * 60 * 1000;
const DEFAULT_RP_NAME = 'OpenChamber';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const PASSKEY_STORE_FILE = path.join(OPENCHAMBER_DATA_DIR, 'ui-passkeys.json');
const createUserId = () => crypto.randomBytes(32).toString('base64url');
const decodeUserId = (value) => {
if (typeof value !== 'string' || !value) {
return null;
}
try {
return Uint8Array.from(Buffer.from(value, 'base64url'));
} catch {
return null;
}
};
const normalizeLabel = (value, fallback) => {
if (typeof value !== 'string') {
return fallback;
}
const normalized = value.trim().replace(/\s+/g, ' ');
return normalized ? normalized.slice(0, 120) : fallback;
};
const normalizeHost = (value) => {
if (typeof value !== 'string') {
return '';
}
const trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('[')) {
const end = trimmed.indexOf(']');
return end >= 0 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
}
const colonIndex = trimmed.indexOf(':');
return (colonIndex >= 0 ? trimmed.slice(0, colonIndex) : trimmed).toLowerCase();
};
const isLocalRpId = (rpID) => rpID === 'localhost' || rpID === '127.0.0.1' || rpID === '::1';
const getCurrentRequestOrigin = (req) => {
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
: '';
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
? req.headers['x-forwarded-host'].split(',')[0].trim()
: '';
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
if (!host) {
return '';
}
return `${protocol}://${host}`;
};
const getCurrentRpId = (req) => {
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
? req.headers['x-forwarded-host'].split(',')[0].trim()
: '';
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
return normalizeHost(host || req.hostname || '');
};
const parseStoredPasskey = (record) => {
if (!record || typeof record !== 'object') {
return null;
}
if (typeof record.id !== 'string' || typeof record.publicKey !== 'string' || typeof record.rpID !== 'string') {
return null;
}
return {
id: record.id,
publicKey: record.publicKey,
counter: typeof record.counter === 'number' && Number.isFinite(record.counter) ? record.counter : 0,
transports: Array.isArray(record.transports)
? record.transports.filter((value) => typeof value === 'string')
: [],
deviceType: typeof record.deviceType === 'string' ? record.deviceType : 'singleDevice',
backedUp: record.backedUp === true,
createdAt: typeof record.createdAt === 'number' ? record.createdAt : Date.now(),
lastUsedAt: typeof record.lastUsedAt === 'number' ? record.lastUsedAt : null,
label: normalizeLabel(record.label, 'Unnamed device'),
rpID: record.rpID,
};
};
export const createUiPasskeys = ({
passwordBinding,
readSettingsFromDiskMigrated,
storeFile = PASSKEY_STORE_FILE,
rpName = DEFAULT_RP_NAME,
challengeTtlMs = DEFAULT_CHALLENGE_TTL_MS,
} = {}) => {
const registrationChallenges = new Map();
const authenticationChallenges = new Map();
const ensureStoreDirectory = () => {
fs.mkdirSync(path.dirname(storeFile), { recursive: true });
};
const persistStore = (store) => {
ensureStoreDirectory();
fs.writeFileSync(storeFile, JSON.stringify(store, null, 2));
};
const createEmptyStore = () => ({
version: DEFAULT_STORE_VERSION,
userID: createUserId(),
passwordBinding,
passkeys: [],
});
const loadStore = () => {
let store = createEmptyStore();
try {
if (fs.existsSync(storeFile)) {
const raw = fs.readFileSync(storeFile, 'utf8');
const parsed = JSON.parse(raw);
store = {
version: DEFAULT_STORE_VERSION,
userID: decodeUserId(parsed?.userID) ? parsed.userID : store.userID,
passwordBinding: typeof parsed?.passwordBinding === 'string' ? parsed.passwordBinding : '',
passkeys: Array.isArray(parsed?.passkeys) ? parsed.passkeys.map(parseStoredPasskey).filter(Boolean) : [],
};
}
} catch (error) {
console.warn('[UI Passkeys] Failed to read passkey store:', error?.message || error);
}
if (!passwordBinding) {
if (store.passkeys.length > 0 || store.passwordBinding) {
store = { ...store, passkeys: [], passwordBinding: '' };
persistStore(store);
}
return store;
}
if (store.passwordBinding !== passwordBinding) {
store = {
version: DEFAULT_STORE_VERSION,
userID: store.userID || createUserId(),
passwordBinding,
passkeys: [],
};
persistStore(store);
return store;
}
if (!fs.existsSync(storeFile)) {
persistStore(store);
}
return store;
};
const cleanupChallengeMap = (map) => {
const now = Date.now();
for (const [requestId, record] of map.entries()) {
if (!record || now >= record.expiresAt) {
map.delete(requestId);
}
}
};
const buildOriginCandidates = async (req) => {
const origins = new Set();
const currentOrigin = getCurrentRequestOrigin(req);
if (currentOrigin) {
origins.add(currentOrigin);
}
try {
const settings = await readSettingsFromDiskMigrated?.();
if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) {
origins.add(new URL(settings.publicOrigin.trim()).origin);
}
} catch {
}
return Array.from(origins);
};
const assertEnabled = () => {
if (!passwordBinding) {
const error = new Error('Passkeys require UI password protection to be enabled');
error.statusCode = 400;
throw error;
}
};
const getPasskeysForRpId = (store, rpID) => store.passkeys.filter((passkey) => passkey.rpID === rpID);
const getStatus = (req) => {
const store = loadStore();
const rpID = getCurrentRpId(req);
return {
enabled: Boolean(passwordBinding),
hasPasskeys: Boolean(rpID) && getPasskeysForRpId(store, rpID).length > 0,
passkeyCount: Boolean(rpID) ? getPasskeysForRpId(store, rpID).length : 0,
rpID,
};
};
const listPasskeys = (req) => {
assertEnabled();
const store = loadStore();
const rpID = getCurrentRpId(req);
if (!rpID) {
return [];
}
return getPasskeysForRpId(store, rpID).map((passkey) => ({
id: passkey.id,
label: passkey.label,
createdAt: passkey.createdAt,
lastUsedAt: passkey.lastUsedAt,
deviceType: passkey.deviceType,
backedUp: passkey.backedUp,
}));
};
const revokePasskey = (req, passkeyId) => {
assertEnabled();
const normalizedPasskeyId = typeof passkeyId === 'string' ? passkeyId.trim() : '';
if (!normalizedPasskeyId) {
const error = new Error('Passkey ID is required');
error.statusCode = 400;
throw error;
}
const store = loadStore();
const rpID = getCurrentRpId(req);
const existingPasskey = store.passkeys.find((passkey) => passkey.id === normalizedPasskeyId && passkey.rpID === rpID);
if (!existingPasskey) {
const error = new Error('Passkey not found for this host');
error.statusCode = 404;
throw error;
}
const nextPasskeys = store.passkeys.filter((passkey) => !(passkey.id === normalizedPasskeyId && passkey.rpID === rpID));
persistStore({
...store,
passwordBinding,
passkeys: nextPasskeys,
});
return {
revoked: true,
passkeyCount: nextPasskeys.filter((passkey) => passkey.rpID === rpID).length,
};
};
const clearAllPasskeys = () => {
assertEnabled();
const store = loadStore();
const clearedCount = store.passkeys.length;
persistStore({
...store,
userID: crypto.randomBytes(32).toString('base64url'),
passwordBinding,
passkeys: [],
});
return {
cleared: true,
clearedCount,
};
};
const beginRegistration = async (req, { label } = {}) => {
assertEnabled();
cleanupChallengeMap(registrationChallenges);
const rpID = getCurrentRpId(req);
if (!rpID) {
const error = new Error('Unable to resolve a valid passkey host for this request');
error.statusCode = 400;
throw error;
}
const currentOrigin = getCurrentRequestOrigin(req);
if (!currentOrigin) {
const error = new Error('Unable to resolve a valid passkey origin for this request');
error.statusCode = 400;
throw error;
}
const store = loadStore();
const userID = decodeUserId(store.userID);
if (!userID) {
const error = new Error('Passkey storage is invalid. Please try again.');
error.statusCode = 500;
throw error;
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userID,
userName: 'openchamber-ui',
userDisplayName: 'OpenChamber UI',
attestationType: 'none',
excludeCredentials: getPasskeysForRpId(store, rpID).map((passkey) => ({
id: passkey.id,
transports: passkey.transports,
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
});
const requestId = crypto.randomBytes(16).toString('base64url');
registrationChallenges.set(requestId, {
challenge: options.challenge,
expectedOrigins: await buildOriginCandidates(req),
expectedRPIDs: [rpID],
rpID,
label: normalizeLabel(label, 'This device'),
createdAt: Date.now(),
expiresAt: Date.now() + challengeTtlMs,
});
return {
requestId,
optionsJSON: options,
};
};
const finishRegistration = async (payload) => {
assertEnabled();
cleanupChallengeMap(registrationChallenges);
const store = loadStore();
const requestId = typeof payload?.requestId === 'string' ? payload.requestId : '';
const response = payload?.response;
const matchingRecord = requestId ? registrationChallenges.get(requestId) : null;
if (!matchingRecord) {
const error = new Error('Passkey setup has expired. Please try again.');
error.statusCode = 400;
throw error;
}
registrationChallenges.delete(requestId);
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: matchingRecord.challenge,
expectedOrigin: matchingRecord.expectedOrigins,
expectedRPID: matchingRecord.expectedRPIDs,
requireUserVerification: true,
});
if (!verification.verified || !verification.registrationInfo) {
const error = new Error('Passkey registration could not be verified');
error.statusCode = 400;
throw error;
}
const {
credential,
credentialDeviceType,
credentialBackedUp,
} = verification.registrationInfo;
const nextPasskeys = store.passkeys.filter((passkey) => passkey.id !== credential.id);
nextPasskeys.push({
id: credential.id,
publicKey: Buffer.from(credential.publicKey).toString('base64url'),
counter: credential.counter,
transports: Array.isArray(credential.transports) ? credential.transports.filter((value) => typeof value === 'string') : [],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
createdAt: Date.now(),
lastUsedAt: null,
label: matchingRecord.label,
rpID: matchingRecord.rpID,
});
persistStore({
...store,
passwordBinding,
passkeys: nextPasskeys,
});
return {
verified: true,
passkeyCount: nextPasskeys.filter((passkey) => passkey.rpID === matchingRecord.rpID).length,
};
};
const beginAuthentication = async (req) => {
assertEnabled();
cleanupChallengeMap(authenticationChallenges);
const store = loadStore();
const rpID = getCurrentRpId(req);
const passkeys = getPasskeysForRpId(store, rpID);
if (!rpID || passkeys.length === 0) {
const error = new Error('No passkeys are registered for this host yet');
error.statusCode = 404;
throw error;
}
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'required',
allowCredentials: passkeys.map((passkey) => ({
id: passkey.id,
transports: passkey.transports,
})),
});
const requestId = crypto.randomBytes(16).toString('base64url');
authenticationChallenges.set(requestId, {
challenge: options.challenge,
expectedOrigins: await buildOriginCandidates(req),
expectedRPIDs: [rpID],
createdAt: Date.now(),
expiresAt: Date.now() + challengeTtlMs,
});
return {
requestId,
optionsJSON: options,
};
};
const finishAuthentication = async (payload) => {
assertEnabled();
cleanupChallengeMap(authenticationChallenges);
const requestId = typeof payload?.requestId === 'string' ? payload.requestId : '';
const response = payload?.response;
const store = loadStore();
const passkey = store.passkeys.find((item) => item.id === response?.id);
if (!passkey) {
const error = new Error('That passkey is not registered for this OpenChamber instance');
error.statusCode = 404;
throw error;
}
const matchingRecord = requestId ? authenticationChallenges.get(requestId) : null;
if (!matchingRecord) {
const error = new Error('Passkey sign-in has expired. Please try again.');
error.statusCode = 400;
throw error;
}
authenticationChallenges.delete(requestId);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: matchingRecord.challenge,
expectedOrigin: matchingRecord.expectedOrigins,
expectedRPID: matchingRecord.expectedRPIDs,
credential: {
id: passkey.id,
publicKey: Buffer.from(passkey.publicKey, 'base64url'),
counter: passkey.counter,
transports: passkey.transports,
},
requireUserVerification: true,
});
if (!verification.verified || !verification.authenticationInfo) {
const error = new Error('Passkey sign-in could not be verified');
error.statusCode = 400;
throw error;
}
const nextPasskeys = store.passkeys.map((item) => (
item.id === passkey.id
? {
...item,
counter: verification.authenticationInfo.newCounter,
lastUsedAt: Date.now(),
}
: item
));
persistStore({
...store,
passwordBinding,
passkeys: nextPasskeys,
});
return { verified: true };
};
const dispose = () => {
registrationChallenges.clear();
authenticationChallenges.clear();
};
return {
enabled: Boolean(passwordBinding),
getStatus,
listPasskeys,
revokePasskey,
clearAllPasskeys,
beginRegistration,
finishRegistration,
beginAuthentication,
finishAuthentication,
dispose,
isLocalRpId,
};
};