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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
37cf7d9c79
commit
75a10ea66c
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 : '';
|
||||
|
||||
@@ -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()`
|
||||
+176
-13
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user