From 75a10ea66c184a891b3dea7faaed428ea005d46b Mon Sep 17 00:00:00 2001 From: Dave Otero <31802309+daveotero@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:36:02 -0600 Subject: [PATCH] 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 --- bun.lock | 47 ++ packages/ui/package.json | 5 +- .../src/components/auth/SessionAuthGate.tsx | 270 ++++++++- .../sections/openchamber/OpenChamberPage.tsx | 7 + .../sections/openchamber/PasskeySettings.tsx | 255 ++++++++ packages/ui/src/lib/passkeys.ts | 200 +++++++ packages/web/package.json | 2 + packages/web/server/index.js | 2 +- .../web/server/lib/opencode/DOCUMENTATION.md | 32 +- .../server/lib/opencode/bootstrap-runtime.js | 5 +- .../web/server/lib/opencode/core-routes.js | 94 +++ packages/web/server/lib/opencode/index.js | 2 +- .../web/server/lib/ui-auth/DOCUMENTATION.md | 38 ++ .../lib/{opencode => ui-auth}/ui-auth.js | 189 +++++- .../web/server/lib/ui-auth/ui-passkeys.js | 545 ++++++++++++++++++ 15 files changed, 1640 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/PasskeySettings.tsx create mode 100644 packages/ui/src/lib/passkeys.ts create mode 100644 packages/web/server/lib/ui-auth/DOCUMENTATION.md rename packages/web/server/lib/{opencode => ui-auth}/ui-auth.js (68%) create mode 100644 packages/web/server/lib/ui-auth/ui-passkeys.js diff --git a/bun.lock b/bun.lock index b8f867ae..efeb920d 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 49d9e17b..66f2eca5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index ef30e5ac..9de99558 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -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 => { console.log('[Frontend Auth] Checking session status...'); @@ -23,7 +36,14 @@ const fetchSessionStatus = async (): Promise => { return response; }; -const submitPassword = async (password: string): Promise => { +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 => { 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 => { '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 = ({ children }) => const [errorMessage, setErrorMessage] = React.useState(''); const [retryAfter, setRetryAfter] = React.useState(undefined); const [isTunnelLocked, setIsTunnelLocked] = React.useState(false); + const [passkeyStatus, setPasskeyStatus] = React.useState(defaultPasskeyStatus); + const [supportsPasskeys, setSupportsPasskeys] = React.useState(false); + const [isPasskeyBusy, setIsPasskeyBusy] = React.useState(false); + const [trustDevice, setTrustDevice] = React.useState(() => readStoredTrustDevice()); + const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null); const passwordInputRef = React.useRef(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 = ({ 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 = ({ 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 = ({ children }) => setState('error'); setIsTunnelLocked(false); } - }, [skipAuth]); + }, [refreshPasskeyStatus, skipAuth]); React.useEffect(() => { if (skipAuth) { @@ -220,6 +294,28 @@ export const SessionAuthGate: React.FC = ({ children }) => const handleSubmit = async (event: React.FormEvent) => { 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 = ({ 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 = ({ 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 ; @@ -306,6 +485,22 @@ export const SessionAuthGate: React.FC = ({ children }) => {!isTunnelLocked && (
+ {canUsePasskey && ( + + )}
@@ -341,6 +536,43 @@ export const SessionAuthGate: React.FC = ({ children }) => )}
+ {canOfferPasskeySetup ? ( +
+ + +
+ ) : ( + + )} {errorMessage && (

{errorMessage} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index dbdce9a4..e61751e3 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -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 = ({ section }) =>

+
+ +
{showAbout && (
@@ -133,6 +137,9 @@ const SessionsSectionContent: React.FC = () => {
+
+ +
); }; diff --git a/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx b/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx new file mode 100644 index 00000000..2032c21b --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx @@ -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(null); + const [isResetting, setIsResetting] = React.useState(false); + const [passkeys, setPasskeys] = React.useState([]); + const [status, setStatus] = React.useState(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 ( +
+
+

Passkeys

+
+ +
+
+
+ Current device +
+
+ + +
+
+ + {!status.enabled && ( +

+ Passkeys are available only when the UI password lock is enabled. +

+ )} + + {status.enabled && !supportsPasskeys && ( +

+ {supportState.reason} +

+ )} + + {isLoading ? ( +

Loading passkeys…

+ ) : passkeys.length === 0 ? ( +

No passkeys saved for this host yet.

+ ) : ( +
+ {passkeys.map((passkey) => ( +
+
+ {passkey.label} +
+
+ + {passkey.lastUsedAt ? `Last used ${formatTimestamp(passkey.lastUsedAt)}` : `Added ${formatTimestamp(passkey.createdAt)}`} + + +
+
+ ))} +
+ )} +
+ + {errorMessage && ( +
+

{errorMessage}

+
+ )} +
+ ); +}; diff --git a/packages/ui/src/lib/passkeys.ts b/packages/ui/src/lib/passkeys.ts new file mode 100644 index 00000000..8faa26b2 --- /dev/null +++ b/packages/ui/src/lib/passkeys.ts @@ -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 => 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 => { + 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 => { + 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 => { + 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); +}; diff --git a/packages/web/package.json b/packages/web/package.json index 396d190e..658fc940 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index ef69a55c..8745c69c 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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'; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 93a8a728..e5cfb40d 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -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` diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index d1a91845..8d4792c6 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -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'); } diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index be59634f..9c3823b4 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -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 : ''; diff --git a/packages/web/server/lib/opencode/index.js b/packages/web/server/lib/opencode/index.js index 12d688b6..fe08e310 100644 --- a/packages/web/server/lib/opencode/index.js +++ b/packages/web/server/lib/opencode/index.js @@ -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, diff --git a/packages/web/server/lib/ui-auth/DOCUMENTATION.md b/packages/web/server/lib/ui-auth/DOCUMENTATION.md new file mode 100644 index 00000000..ddcc1eec --- /dev/null +++ b/packages/web/server/lib/ui-auth/DOCUMENTATION.md @@ -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()` diff --git a/packages/web/server/lib/opencode/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js similarity index 68% rename from packages/web/server/lib/opencode/ui-auth.js rename to packages/web/server/lib/ui-auth/ui-auth.js index 7349f438..c3373cdb 100644 --- a/packages/web/server/lib/opencode/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -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; diff --git a/packages/web/server/lib/ui-auth/ui-passkeys.js b/packages/web/server/lib/ui-auth/ui-passkeys.js new file mode 100644 index 00000000..8ebe34ff --- /dev/null +++ b/packages/web/server/lib/ui-auth/ui-passkeys.js @@ -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, + }; +};