diff --git a/README.md b/README.md index 2294349..f9cc5a6 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ It can also be set using environment variables: - Authentik - AWS Cognito - Battle.net +- Bluesky (AT Protocol) - Discord - Dropbox - Facebook @@ -638,26 +639,26 @@ Checkout the [`SessionConfig`](https://github.com/unjs/h3/blob/c04c458810e34eb15 ```bash # Install dependencies -npm install +pnpm install # Generate type stubs -npm run dev:prepare +pnpm run dev:prepare # Develop with the playground -npm run dev +pnpm run dev # Build the playground -npm run dev:build +pnpm run dev:build # Run ESLint -npm run lint +pnpm run lint # Run Vitest -npm run test -npm run test:watch +pnpm run test +pnpm run test:watch # Release new version -npm run release +pnpm run release ``` diff --git a/package.json b/package.json index 0fcab76..86774f5 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,9 @@ }, "peerDependencies": { "@simplewebauthn/browser": "^11.0.0", - "@simplewebauthn/server": "^11.0.0" + "@simplewebauthn/server": "^11.0.0", + "@atproto/oauth-client-node": "^0.2.0", + "@atproto/api": "^0.13.15" }, "peerDependenciesMeta": { "@simplewebauthn/browser": { @@ -56,6 +58,12 @@ }, "@simplewebauthn/server": { "optional": true + }, + "@atproto/oauth-client-node": { + "optional": true + }, + "@atproto/api": { + "optional": true } }, "devDependencies": { diff --git a/playground/app.vue b/playground/app.vue index 761768c..b0f2ba7 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -27,6 +27,22 @@ const providers = computed(() => disabled: Boolean(user.value?.github), icon: 'i-simple-icons-github', }, + { + label: user.value?.bluesky || 'Bluesky', + click() { + const handle = prompt('Enter your Bluesky handle') + if (handle) { + navigateTo({ + path: '/auth/bluesky', + query: { handle }, + }, { + external: true, + }) + } + }, + disabled: Boolean(user.value?.bluesky), + icon: 'i-simple-icons-bluesky', + }, { label: user.value?.gitlab || 'GitLab', to: '/auth/gitlab', diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 71b55c3..3ef3d20 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -1,5 +1,6 @@ declare module '#auth-utils' { interface User { + bluesky?: string webauthn?: string email?: string password?: string diff --git a/playground/server/routes/auth/bluesky.get.ts b/playground/server/routes/auth/bluesky.get.ts new file mode 100644 index 0000000..10b12a1 --- /dev/null +++ b/playground/server/routes/auth/bluesky.get.ts @@ -0,0 +1,12 @@ +export default defineOAuthBlueskyEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + bluesky: user.did, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6464ebc..8b645ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@adonisjs/hash': specifier: ^9.0.5 version: 9.0.5 + '@atproto/api': + specifier: ^0.13.15 + version: 0.13.15 + '@atproto/oauth-client-node': + specifier: ^0.2.0 + version: 0.2.0 '@nuxt/kit': specifier: ^3.14.159 version: 3.14.159(magicast@0.3.5)(rollup@3.29.4) @@ -144,6 +150,69 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@atproto-labs/did-resolver@0.1.5': + resolution: {integrity: sha512-uoCb+P0N4du5NiZt6ohVEbSDdijXBJlQwSlWLHX0rUDtEVV+g3aEGe7jUW94lWpqQmRlQ5xcyd9owleMibNxZw==} + + '@atproto-labs/fetch-node@0.1.3': + resolution: {integrity: sha512-KX3ogPJt6dXNppWImQ9omfhrc8t73WrJaxHMphRAqQL8jXxKW5NBCTjSuwroBkJ1pj1aValBrc5NpdYu+H/9Qg==} + + '@atproto-labs/fetch@0.1.1': + resolution: {integrity: sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww==} + + '@atproto-labs/handle-resolver-node@0.1.7': + resolution: {integrity: sha512-3pXUB8/twMPXUz+zMjSVTA5acxnizC7PF+EsjLKwirwVzLRrTcFQkyHXGTrdUfIQq+S1eLq7b6H7ZKqMOX9VQQ==} + + '@atproto-labs/handle-resolver@0.1.4': + resolution: {integrity: sha512-tnGUD2mQ6c8xHs3eeVJgwYqM3FHoTZZbOcOGKqO1A5cuIG+gElwEhpWwpwX5LI7FF4J8eS9BOHLl3NFS7Q8QXg==} + + '@atproto-labs/identity-resolver@0.1.5': + resolution: {integrity: sha512-0r1d3ZwzPIuYcIdkKQcl2qUhUXTCCqCYOaum7cldyr/CJwQ3IAvE0pngTtZ+Mdw3KUMK7Jw7Jfh2IymhTvp5IQ==} + + '@atproto-labs/pipe@0.1.0': + resolution: {integrity: sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==} + + '@atproto-labs/simple-store-memory@0.1.1': + resolution: {integrity: sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==} + + '@atproto-labs/simple-store@0.1.1': + resolution: {integrity: sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==} + + '@atproto/api@0.13.15': + resolution: {integrity: sha512-zC8KH+Spcr2HE6vD4hddP5rZpWrGUTWvL8hQmUxa/sAnlsjoFyv/Oja8ZHGXoDsAl6ie5Gd77cPNxaxWH/yIBQ==} + + '@atproto/common-web@0.3.1': + resolution: {integrity: sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==} + + '@atproto/did@0.1.3': + resolution: {integrity: sha512-ULD8Gw/KRRwLFZ2Z2L4DjmdOMrg8IYYlcjdSc+GQ2/QJSVnD2zaJJVTLd3vls121wGt/583rNaiZTT2DpBze4w==} + + '@atproto/jwk-jose@0.1.2': + resolution: {integrity: sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==} + + '@atproto/jwk-webcrypto@0.1.2': + resolution: {integrity: sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==} + + '@atproto/jwk@0.1.1': + resolution: {integrity: sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==} + + '@atproto/lexicon@0.4.2': + resolution: {integrity: sha512-CXoOkhcdF3XVUnR2oNgCs2ljWfo/8zUjxL5RIhJW/UNLp/FSl+KpF8Jm5fbk8Y/XXVPGRAsv9OYfxyU/14N/pw==} + + '@atproto/oauth-client-node@0.2.0': + resolution: {integrity: sha512-tvJJrMsZ0KXnwtW43zymsXauV9q8fl+Rfr+uRZwyOSi9t2wUIYQ3KqtdDB8hhWoU1I7clQId2yAYuWjSj0yP2w==} + + '@atproto/oauth-client@0.3.0': + resolution: {integrity: sha512-SX0FPeTiFUdpkqPfMmi566rbYaLWz5P/OxVPfpVRNJ95gSfqc9NbldB7IUBf3GgSvNfSyFyG4OEpltPg7mermQ==} + + '@atproto/oauth-types@0.2.0': + resolution: {integrity: sha512-v/4ht6eRh0yOu2iuuWujZdnJBamPKimdy8k0Xan8cVZ+a2i83UkhIIU+S/XUbbvJ4a64wLPZrS9IDd0K5XYYTQ==} + + '@atproto/syntax@0.3.0': + resolution: {integrity: sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==} + + '@atproto/xrpc@0.6.3': + resolution: {integrity: sha512-S3tRvOdA9amPkKLll3rc4vphlDitLrkN5TwWh5Tu/jzk7mnobVVE3akYgICV9XCNHKjWM+IAPxFFI2qi+VW6nQ==} + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -1481,21 +1550,41 @@ packages: rollup: optional: true + '@rollup/rollup-android-arm-eabi@4.22.0': + resolution: {integrity: sha512-/IZQvg6ZR0tAkEi4tdXOraQoWeJy9gbQ/cx4I7k9dJaCk9qrXEcdouxRVz5kZXt5C2bQ9pILoAA+KB4C/d3pfw==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm-eabi@4.24.4': resolution: {integrity: sha512-jfUJrFct/hTA0XDM5p/htWKoNNTbDLY0KRwEt6pyOA6k2fmk0WVwl65PdUdJZgzGEHWx+49LilkcSaumQRyNQw==} cpu: [arm] os: [android] + '@rollup/rollup-android-arm64@4.22.0': + resolution: {integrity: sha512-ETHi4bxrYnvOtXeM7d4V4kZWixib2jddFacJjsOjwbgYSRsyXYtZHC4ht134OsslPIcnkqT+TKV4eU8rNBKyyQ==} + cpu: [arm64] + os: [android] + '@rollup/rollup-android-arm64@4.24.4': resolution: {integrity: sha512-j4nrEO6nHU1nZUuCfRKoCcvh7PIywQPUCBa2UsootTHvTHIoIu2BzueInGJhhvQO/2FTRdNYpf63xsgEqH9IhA==} cpu: [arm64] os: [android] + '@rollup/rollup-darwin-arm64@4.22.0': + resolution: {integrity: sha512-ZWgARzhSKE+gVUX7QWaECoRQsPwaD8ZR0Oxb3aUpzdErTvlEadfQpORPXkKSdKbFci9v8MJfkTtoEHnnW9Ulng==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-arm64@4.24.4': resolution: {integrity: sha512-GmU/QgGtBTeraKyldC7cDVVvAJEOr3dFLKneez/n7BvX57UdhOqDsVwzU7UOnYA7AAOt+Xb26lk79PldDHgMIQ==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-x64@4.22.0': + resolution: {integrity: sha512-h0ZAtOfHyio8Az6cwIGS+nHUfRMWBDO5jXB8PQCARVF6Na/G6XS2SFxDl8Oem+S5ZsHQgtsI7RT4JQnI1qrlaw==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.24.4': resolution: {integrity: sha512-N6oDBiZCBKlwYcsEPXGDE4g9RoxZLK6vT98M8111cW7VsVJFpNEqvJeIPfsCzbf0XEakPslh72X0gnlMi4Ddgg==} cpu: [x64] @@ -1511,61 +1600,121 @@ packages: cpu: [x64] os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.22.0': + resolution: {integrity: sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.24.4': resolution: {integrity: sha512-10ICosOwYChROdQoQo589N5idQIisxjaFE/PAnX2i0Zr84mY0k9zul1ArH0rnJ/fpgiqfu13TFZR5A5YJLOYZA==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.22.0': + resolution: {integrity: sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.24.4': resolution: {integrity: sha512-ySAfWs69LYC7QhRDZNKqNhz2UKN8LDfbKSMAEtoEI0jitwfAG2iZwVqGACJT+kfYvvz3/JgsLlcBP+WWoKCLcw==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.22.0': + resolution: {integrity: sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.24.4': resolution: {integrity: sha512-uHYJ0HNOI6pGEeZ/5mgm5arNVTI0nLlmrbdph+pGXpC9tFHFDQmDMOEqkmUObRfosJqpU8RliYoGz06qSdtcjg==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.22.0': + resolution: {integrity: sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.24.4': resolution: {integrity: sha512-38yiWLemQf7aLHDgTg85fh3hW9stJ0Muk7+s6tIkSUOMmi4Xbv5pH/5Bofnsb6spIwD5FJiR+jg71f0CH5OzoA==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.22.0': + resolution: {integrity: sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.24.4': resolution: {integrity: sha512-q73XUPnkwt9ZNF2xRS4fvneSuaHw2BXuV5rI4cw0fWYVIWIBeDZX7c7FWhFQPNTnE24172K30I+dViWRVD9TwA==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.22.0': + resolution: {integrity: sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.24.4': resolution: {integrity: sha512-Aie/TbmQi6UXokJqDZdmTJuZBCU3QBDA8oTKRGtd4ABi/nHgXICulfg1KI6n9/koDsiDbvHAiQO3YAUNa/7BCw==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.22.0': + resolution: {integrity: sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.24.4': resolution: {integrity: sha512-P8MPErVO/y8ohWSP9JY7lLQ8+YMHfTI4bAdtCi3pC2hTeqFJco2jYspzOzTUB8hwUWIIu1xwOrJE11nP+0JFAQ==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.22.0': + resolution: {integrity: sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.24.4': resolution: {integrity: sha512-K03TljaaoPK5FOyNMZAAEmhlyO49LaE4qCsr0lYHUKyb6QacTNF9pnfPpXnFlFD3TXuFbFbz7tJ51FujUXkXYA==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.22.0': + resolution: {integrity: sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.24.4': resolution: {integrity: sha512-VJYl4xSl/wqG2D5xTYncVWW+26ICV4wubwN9Gs5NrqhJtayikwCXzPL8GDsLnaLU3WwhQ8W02IinYSFJfyo34Q==} cpu: [x64] os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.22.0': + resolution: {integrity: sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.24.4': resolution: {integrity: sha512-ku2GvtPwQfCqoPFIJCqZ8o7bJcj+Y54cZSr43hHca6jLwAiCbZdBUOrqE6y29QFajNAzzpIOwsckaTFmN6/8TA==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.22.0': + resolution: {integrity: sha512-+3qZ4rer7t/QsC5JwMpcvCVPRcJt1cJrYS/TMJZzXIJbxWFQEVhrIc26IhB+5Z9fT9umfVc+Es2mOZgl+7jdJQ==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.24.4': resolution: {integrity: sha512-V3nCe+eTt/W6UYNr/wGvO1fLpHUrnlirlypZfKCT1fG6hWfqhPgQV/K/mRBXBpxc0eKLIF18pIOFVPh0mqHjlg==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.22.0': + resolution: {integrity: sha512-YdicNOSJONVx/vuPkgPTyRoAPx3GbknBZRCOUkK84FJ/YTfs/F0vl/YsMscrB6Y177d+yDRcj+JWMPMCgshwrA==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.24.4': resolution: {integrity: sha512-LTw1Dfd0mBIEqUVCxbvTE/LLo+9ZxVC9k99v1v4ahg9Aak6FpqOfNu5kRkeTAn0wphoC4JU7No1/rL+bBCEwhg==} cpu: [x64] @@ -1629,6 +1778,9 @@ packages: '@types/bytes@3.1.4': resolution: {integrity: sha512-A0uYgOj3zNc4hNjHc5lYUfJQ/HVyBXiUMKdXd7ysclaE6k9oJdavQzODHuwjpUu2/boCP8afjQYi8z/GtvNCWA==} + '@types/estree@1.0.5': + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -2100,6 +2252,9 @@ packages: peerDependencies: postcss: ^8.1.0 + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} @@ -3329,6 +3484,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3344,6 +3502,9 @@ packages: resolution: {integrity: sha512-H5UpaUI+aHOqZXlYOaFP/8AzKsg+guWu+Pr3Y8i7+Y3zr1aXAvCvTAQ1RxSc6oVD8R8c7brgNtTVP91E7upH/g==} hasBin: true + jose@5.9.6: + resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} + js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -3680,6 +3841,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -4246,6 +4410,9 @@ packages: protocols@2.0.1: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} + psl@1.10.0: + resolution: {integrity: sha512-KSKHEbjAnpUuAUserOq0FxGXCUrzC3WniuSJhvdbs102rL55266ZcHBqLWOsG30spQMlPdpy7icATiAQehg/iA==} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -4406,6 +4573,11 @@ packages: engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true + rollup@4.22.0: + resolution: {integrity: sha512-W21MUIFPZ4+O2Je/EU+GP3iz7PH4pVPUXSbEZdatQnxo29+3rsUjgrJmzuAZU24z7yRAnFN6ukxeAhZh/c7hzg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rollup@4.24.4: resolution: {integrity: sha512-vGorVWIsWfX3xbcyAS+I047kFKapHYivmkaT63Smj77XwvLSJos6M1xGqZnBPFQFBRZDOcG1QnYEIxAvTr/HjA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4760,6 +4932,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tlds@1.255.0: + resolution: {integrity: sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==} + hasBin: true + to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -4850,6 +5026,9 @@ packages: ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + uint8arrays@3.0.0: + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + ultrahtml@1.5.3: resolution: {integrity: sha512-GykOvZwgDWZlTQMtp5jrD4BVL+gNn2NVlVafjcFUJ7taY20tqYdwdoWBFy6GBJsNTZe1GkGPkSl5knQAjtgceg==} @@ -4871,6 +5050,10 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici@6.21.0: + resolution: {integrity: sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==} + engines: {node: '>=18.17'} + unenv@1.10.0: resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} @@ -5327,6 +5510,141 @@ snapshots: '@antfu/utils@0.7.10': {} + '@atproto-labs/did-resolver@0.1.5': + dependencies: + '@atproto-labs/fetch': 0.1.1 + '@atproto-labs/pipe': 0.1.0 + '@atproto-labs/simple-store': 0.1.1 + '@atproto-labs/simple-store-memory': 0.1.1 + '@atproto/did': 0.1.3 + zod: 3.23.8 + + '@atproto-labs/fetch-node@0.1.3': + dependencies: + '@atproto-labs/fetch': 0.1.1 + '@atproto-labs/pipe': 0.1.0 + ipaddr.js: 2.2.0 + psl: 1.10.0 + undici: 6.21.0 + + '@atproto-labs/fetch@0.1.1': + dependencies: + '@atproto-labs/pipe': 0.1.0 + optionalDependencies: + zod: 3.23.8 + + '@atproto-labs/handle-resolver-node@0.1.7': + dependencies: + '@atproto-labs/fetch-node': 0.1.3 + '@atproto-labs/handle-resolver': 0.1.4 + '@atproto/did': 0.1.3 + + '@atproto-labs/handle-resolver@0.1.4': + dependencies: + '@atproto-labs/simple-store': 0.1.1 + '@atproto-labs/simple-store-memory': 0.1.1 + '@atproto/did': 0.1.3 + zod: 3.23.8 + + '@atproto-labs/identity-resolver@0.1.5': + dependencies: + '@atproto-labs/did-resolver': 0.1.5 + '@atproto-labs/handle-resolver': 0.1.4 + '@atproto/syntax': 0.3.0 + + '@atproto-labs/pipe@0.1.0': {} + + '@atproto-labs/simple-store-memory@0.1.1': + dependencies: + '@atproto-labs/simple-store': 0.1.1 + lru-cache: 10.4.3 + + '@atproto-labs/simple-store@0.1.1': {} + + '@atproto/api@0.13.15': + dependencies: + '@atproto/common-web': 0.3.1 + '@atproto/lexicon': 0.4.2 + '@atproto/syntax': 0.3.0 + '@atproto/xrpc': 0.6.3 + await-lock: 2.2.2 + multiformats: 9.9.0 + tlds: 1.255.0 + zod: 3.23.8 + + '@atproto/common-web@0.3.1': + dependencies: + graphemer: 1.4.0 + multiformats: 9.9.0 + uint8arrays: 3.0.0 + zod: 3.23.8 + + '@atproto/did@0.1.3': + dependencies: + zod: 3.23.8 + + '@atproto/jwk-jose@0.1.2': + dependencies: + '@atproto/jwk': 0.1.1 + jose: 5.9.6 + + '@atproto/jwk-webcrypto@0.1.2': + dependencies: + '@atproto/jwk': 0.1.1 + '@atproto/jwk-jose': 0.1.2 + + '@atproto/jwk@0.1.1': + dependencies: + multiformats: 9.9.0 + zod: 3.23.8 + + '@atproto/lexicon@0.4.2': + dependencies: + '@atproto/common-web': 0.3.1 + '@atproto/syntax': 0.3.0 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.23.8 + + '@atproto/oauth-client-node@0.2.0': + dependencies: + '@atproto-labs/did-resolver': 0.1.5 + '@atproto-labs/handle-resolver-node': 0.1.7 + '@atproto-labs/simple-store': 0.1.1 + '@atproto/did': 0.1.3 + '@atproto/jwk': 0.1.1 + '@atproto/jwk-jose': 0.1.2 + '@atproto/jwk-webcrypto': 0.1.2 + '@atproto/oauth-client': 0.3.0 + '@atproto/oauth-types': 0.2.0 + + '@atproto/oauth-client@0.3.0': + dependencies: + '@atproto-labs/did-resolver': 0.1.5 + '@atproto-labs/fetch': 0.1.1 + '@atproto-labs/handle-resolver': 0.1.4 + '@atproto-labs/identity-resolver': 0.1.5 + '@atproto-labs/simple-store': 0.1.1 + '@atproto-labs/simple-store-memory': 0.1.1 + '@atproto/did': 0.1.3 + '@atproto/jwk': 0.1.1 + '@atproto/oauth-types': 0.2.0 + '@atproto/xrpc': 0.6.3 + multiformats: 9.9.0 + zod: 3.23.8 + + '@atproto/oauth-types@0.2.0': + dependencies: + '@atproto/jwk': 0.1.1 + zod: 3.23.8 + + '@atproto/syntax@0.3.0': {} + + '@atproto/xrpc@0.6.3': + dependencies: + '@atproto/lexicon': 0.4.2 + zod: 3.23.8 + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -7091,15 +7409,27 @@ snapshots: optionalDependencies: rollup: 4.24.4 + '@rollup/rollup-android-arm-eabi@4.22.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.24.4': optional: true + '@rollup/rollup-android-arm64@4.22.0': + optional: true + '@rollup/rollup-android-arm64@4.24.4': optional: true + '@rollup/rollup-darwin-arm64@4.22.0': + optional: true + '@rollup/rollup-darwin-arm64@4.24.4': optional: true + '@rollup/rollup-darwin-x64@4.22.0': + optional: true + '@rollup/rollup-darwin-x64@4.24.4': optional: true @@ -7109,39 +7439,75 @@ snapshots: '@rollup/rollup-freebsd-x64@4.24.4': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.22.0': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.24.4': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.22.0': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.24.4': optional: true + '@rollup/rollup-linux-arm64-gnu@4.22.0': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.24.4': optional: true + '@rollup/rollup-linux-arm64-musl@4.22.0': + optional: true + '@rollup/rollup-linux-arm64-musl@4.24.4': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.22.0': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.24.4': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.22.0': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.24.4': optional: true + '@rollup/rollup-linux-s390x-gnu@4.22.0': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.24.4': optional: true + '@rollup/rollup-linux-x64-gnu@4.22.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.24.4': optional: true + '@rollup/rollup-linux-x64-musl@4.22.0': + optional: true + '@rollup/rollup-linux-x64-musl@4.24.4': optional: true + '@rollup/rollup-win32-arm64-msvc@4.22.0': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.24.4': optional: true + '@rollup/rollup-win32-ia32-msvc@4.22.0': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.24.4': optional: true + '@rollup/rollup-win32-x64-msvc@4.22.0': + optional: true + '@rollup/rollup-win32-x64-msvc@4.24.4': optional: true @@ -7213,6 +7579,8 @@ snapshots: '@types/bytes@3.1.4': {} + '@types/estree@1.0.5': {} + '@types/estree@1.0.6': {} '@types/http-proxy@1.17.15': @@ -7866,6 +8234,8 @@ snapshots: postcss: 8.4.47 postcss-value-parser: 4.2.0 + await-lock@2.2.2: {} + b4a@1.6.6: {} balanced-match@1.0.2: {} @@ -9230,6 +9600,8 @@ snapshots: isexe@2.0.0: {} + iso-datestring-validator@2.2.2: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -9242,6 +9614,8 @@ snapshots: jiti@2.4.0: {} + jose@5.9.6: {} + js-levenshtein@1.1.6: {} js-tokens@4.0.0: {} @@ -9575,6 +9949,8 @@ snapshots: muggle-string@0.4.1: {} + multiformats@9.9.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -10426,6 +10802,10 @@ snapshots: protocols@2.0.1: {} + psl@1.10.0: + dependencies: + punycode: 2.3.1 + pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -10600,6 +10980,28 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + rollup@4.22.0: + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.22.0 + '@rollup/rollup-android-arm64': 4.22.0 + '@rollup/rollup-darwin-arm64': 4.22.0 + '@rollup/rollup-darwin-x64': 4.22.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.22.0 + '@rollup/rollup-linux-arm-musleabihf': 4.22.0 + '@rollup/rollup-linux-arm64-gnu': 4.22.0 + '@rollup/rollup-linux-arm64-musl': 4.22.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.22.0 + '@rollup/rollup-linux-riscv64-gnu': 4.22.0 + '@rollup/rollup-linux-s390x-gnu': 4.22.0 + '@rollup/rollup-linux-x64-gnu': 4.22.0 + '@rollup/rollup-linux-x64-musl': 4.22.0 + '@rollup/rollup-win32-arm64-msvc': 4.22.0 + '@rollup/rollup-win32-ia32-msvc': 4.22.0 + '@rollup/rollup-win32-x64-msvc': 4.22.0 + fsevents: 2.3.3 + rollup@4.24.4: dependencies: '@types/estree': 1.0.6 @@ -11008,6 +11410,8 @@ snapshots: tinyspy@3.0.2: {} + tlds@1.255.0: {} + to-fast-properties@2.0.0: {} to-regex-range@5.0.1: @@ -11065,6 +11469,10 @@ snapshots: ufo@1.5.4: {} + uint8arrays@3.0.0: + dependencies: + multiformats: 9.9.0 + ultrahtml@1.5.3: {} unbuild@2.0.0(typescript@5.6.3)(vue-tsc@2.1.10(typescript@5.6.3)): @@ -11113,6 +11521,8 @@ snapshots: undici-types@6.19.8: {} + undici@6.21.0: {} + unenv@1.10.0: dependencies: consola: 3.2.3 @@ -11468,7 +11878,7 @@ snapshots: dependencies: esbuild: 0.21.5 postcss: 8.4.47 - rollup: 4.24.4 + rollup: 4.22.0 optionalDependencies: '@types/node': 22.5.5 fsevents: 2.3.3 diff --git a/src/module.ts b/src/module.ts index 4e3bac9..3d3b6f0 100644 --- a/src/module.ts +++ b/src/module.ts @@ -15,6 +15,8 @@ import { defu } from 'defu' import { randomUUID } from 'uncrypto' import type { ScryptConfig } from '@adonisjs/hash/types' import type { SessionConfig } from 'h3' +import { atprotoProviderDefaultClientMetadata, atprotoProviders, getClientMetadataFilename } from './utils/atproto' +import type { AtprotoProviderClientMetadata } from './runtime/types/atproto' // Module options TypeScript interface definition export interface ModuleOptions { @@ -23,6 +25,11 @@ export interface ModuleOptions { * @default false */ webAuthn?: boolean + /** + * Enable atproto OAuth + * @default false + */ + atproto?: boolean /** * Hash options used for password hashing */ @@ -54,6 +61,7 @@ export default defineNuxtModule({ // Default configuration options of the Nuxt module defaults: { webAuthn: false, + atproto: false, hash: { scrypt: {}, }, @@ -97,6 +105,20 @@ export default defineNuxtModule({ } addServerImportsDir(resolver.resolve('./runtime/server/lib/webauthn')) } + + if (options.atproto) { + const missingDeps: string[] = [] + const peerDeps = ['@atproto/oauth-client-node', '@atproto/api'] + for (const pkg of peerDeps) { + await import(pkg).catch(() => { + missingDeps.push(pkg) + }) + } + if (missingDeps.length > 0) { + logger.withTag('nuxt-auth-utils').error(`Missing dependencies for \`atproto\`, please install with:\n\n\`npx nypm i ${missingDeps.join(' ')}\``) + process.exit(1) + } + } addServerImportsDir(resolver.resolve('./runtime/server/utils')) addServerHandler({ handler: resolver.resolve('./runtime/server/api/session.delete'), @@ -227,6 +249,19 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + + // Atproto OAuth + for (const provider of atprotoProviders) { + // @ts-expect-error Not typesafe, but avoids repeating the same code for each provider + runtimeConfig.oauth[provider] = defu(runtimeConfig.oauth[provider], atprotoProviderDefaultClientMetadata) as AtprotoProviderClientMetadata + + addServerHandler({ + handler: resolver.resolve('./runtime/server/routes/atproto/client-metadata.json.get.ts'), + route: '/' + getClientMetadataFilename(provider, runtimeConfig.oauth[provider]), + method: 'get', + }) + } + // Keycloak OAuth runtimeConfig.oauth.keycloak = defu(runtimeConfig.oauth.keycloak, { clientId: '', diff --git a/src/runtime/server/lib/oauth/bluesky.ts b/src/runtime/server/lib/oauth/bluesky.ts new file mode 100644 index 0000000..9b01822 --- /dev/null +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -0,0 +1,154 @@ +import type { H3Event } from 'h3' +import { createError, eventHandler, getQuery, sendRedirect } from 'h3' +import type { Storage, StorageValue } from 'unstorage' +import { NodeOAuthClient, OAuthCallbackError, OAuthResolverError, OAuthResponseError } from '@atproto/oauth-client-node' +import type { + NodeSavedSession, + NodeSavedSessionStore, + NodeSavedState, + NodeSavedStateStore, +} from '@atproto/oauth-client-node' +import { Agent } from '@atproto/api' +import type { AppBskyActorDefs } from '@atproto/api' +import { getAtprotoClientMetadata } from '../../utils/atproto' +import type { OAuthConfig } from '#auth-utils' +import { useStorage } from '#imports' + +export interface OAuthBlueskyConfig { + /** + * Redirect URL to use for this authorization flow. It should only consist of the path, as the hostname must always match the client id's hostname. + * @default process.env.NUXT_OAUTH_BLUESKY_REDIRECT_URL + * @example '/auth/bluesky' + */ + redirectUrl?: string + /** + * Bluesky OAuth Scope. The `atproto` scope is required and will be added if not present. + * @default ['atproto'] + * @see https://atproto.com/specs/oauth#authorization-scopes + * @example ['atproto', 'transition:generic'] + */ + scope?: string[] +} + +type BlueSkyUser = AppBskyActorDefs.ProfileViewDetailed | Pick +type BlueSkyTokens = NodeSavedSession['tokenSet'] + +export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + const clientMetadata = getAtprotoClientMetadata(event, 'bluesky', config) + const scopes = clientMetadata.scope?.split(' ') ?? [] + + const storage = useStorage() + const sessionStore = new SessionStore(storage) + const stateStore = new StateStore(storage) + + const client = new NodeOAuthClient({ + stateStore, + sessionStore, + // Todo: This needs to be exposed publicly so that the authorization server can validate the client + // It is not verified by Bluesky yet, but it might be in the future + clientMetadata: clientMetadata, + }) + + const query = getQuery(event) + + if (!query.code) { + try { + const handle = query.handle?.toString() + if (!handle) throw createError({ + statusCode: 400, + message: 'Query parameter `handle` empty or missing. Please provide a valid Bluesky handle.', + }) + + const url = await client.authorize(handle, { scope: clientMetadata.scope }) + return sendRedirect(event, url.toString()) + } + catch (err) { + const error = (() => { + switch (true) { + case err instanceof OAuthResponseError: + return createError({ + statusCode: 500, + message: `Bluesky login failed: ${err.errorDescription || 'Unknown error'}`, + data: err.payload, + }) + + case err instanceof OAuthResolverError: + return createError({ + statusCode: 400, + message: `Bluesky login failed: ${err.message || 'Unknown error'}`, + }) + + default: + throw err + } + })() + + if (!onError) throw error + return onError(event, error) + } + } + + try { + const { session } = await client.callback(new URLSearchParams(query as Record)) + const sessionInfo = await sessionStore.get(session.did) + const profile = scopes.includes('transition:generic') + ? (await new Agent(session).getProfile({ actor: session.did })).data + : null + + return onSuccess(event, { + user: profile ?? { did: session.did }, + tokens: sessionInfo!.tokenSet, + }) + } + catch (err) { + if (!(err instanceof OAuthCallbackError)) throw err + const error = createError({ + statusCode: 500, + message: `Bluesky login failed: ${err.message || 'Unknown error'}`, + }) + if (!onError) throw error + return onError(event, error) + } + }) +} + +export class StateStore implements NodeSavedStateStore { + private readonly keyPrefix = 'oauth:bluesky:state:' + + constructor(private storage: Storage) {} + + async get(key: string): Promise { + const result = await this.storage.get(this.keyPrefix + key) + if (!result) return + return result + } + + async set(key: string, val: NodeSavedState) { + await this.storage.set(this.keyPrefix + key, val) + } + + async del(key: string) { + await this.storage.del(this.keyPrefix + key) + } +} + +export class SessionStore implements NodeSavedSessionStore { + private readonly keyPrefix = 'oauth:bluesky:session:' + + constructor(private storage: Storage) {} + + async get(key: string): Promise { + const result = await this.storage.get(this.keyPrefix + key) + if (!result) return + return result + } + + async set(key: string, val: NodeSavedSession) { + await this.storage.set(this.keyPrefix + key, val) + } + + async del(key: string) { + await this.storage.del(this.keyPrefix + key) + } +} diff --git a/src/runtime/server/routes/atproto/client-metadata.json.get.ts b/src/runtime/server/routes/atproto/client-metadata.json.get.ts new file mode 100644 index 0000000..ae68b38 --- /dev/null +++ b/src/runtime/server/routes/atproto/client-metadata.json.get.ts @@ -0,0 +1,23 @@ +import { defineEventHandler, createError } from 'h3' +import { getAtprotoClientMetadata } from '../../utils/atproto' +import { atprotoProviders, getClientMetadataFilename } from '../../../../utils/atproto' +import type { AtprotoProviderClientMetadata } from '../../../types/atproto' +import { useRuntimeConfig } from '#imports' + +export default defineEventHandler((event) => { + const path = event.path.slice(1) + const runtimeConfig = useRuntimeConfig(event) + + for (const provider of atprotoProviders) { + const config: AtprotoProviderClientMetadata = runtimeConfig.oauth[provider] + + if (getClientMetadataFilename(provider, config) === path) { + return getAtprotoClientMetadata(event, provider) + } + } + + throw createError({ + statusCode: 404, + message: 'Provider not found', + }) +}) diff --git a/src/runtime/server/utils/atproto.ts b/src/runtime/server/utils/atproto.ts new file mode 100644 index 0000000..4a472d7 --- /dev/null +++ b/src/runtime/server/utils/atproto.ts @@ -0,0 +1,65 @@ +import type { H3Event } from 'h3' +import type { OAuthClientMetadataInput, OAuthGrantType } from '@atproto/oauth-client-node' +import type { AtprotoProviderClientMetadata } from '../../types/atproto' +import type { OAuthBlueskyConfig } from '../lib/oauth/bluesky' +import { getOAuthRedirectURL } from '../lib/utils' +import { getClientMetadataFilename } from '../../../utils/atproto' +import type { OAuthConfig, OAuthProvider } from '#auth-utils' +import { getRequestURL, useRuntimeConfig } from '#imports' + +export function getAtprotoClientMetadata( + event: H3Event, + provider: OAuthProvider, + config?: OAuthConfig['config'], +): OAuthClientMetadataInput { + const providerRuntimeConfig: AtprotoProviderClientMetadata = useRuntimeConfig(event).oauth[provider] as AtprotoProviderClientMetadata + const scopes = [...new Set(['atproto', ...config?.scope ?? [], ...providerRuntimeConfig.scope ?? []])] + const scope = scopes.join(' ') + + const grantTypes = [...new Set(['authorization_code', ...providerRuntimeConfig.grantTypes ?? []])] as [OAuthGrantType, ...OAuthGrantType[]] + + const requestURL = getRequestURL(event) + const baseUrl = `${requestURL.protocol}//${requestURL.host}` + + /** + * The redirect URL must be a valid URL, so we need to parse it to ensure it is correct. Will use the following order: + * 1. URL provided as part of the config of the event handler, on the condition that it was listed in the redirect URIs. + * 2. First URL provided in the runtime config. + * 3. The URL of the current request. + */ + const redirectURL = new URL( + (config?.redirectUrl && baseUrl + config.redirectUrl) + || (providerRuntimeConfig.redirectUris[0] && baseUrl + providerRuntimeConfig.redirectUris[0]) + || getOAuthRedirectURL(event), + ) + + const dev = import.meta.dev + if (dev && redirectURL.hostname === 'localhost') { + // For local development, Bluesky authorization servers allow "http://127.0.0.1" as a special value for redirect URIs + redirectURL.hostname = '127.0.0.1' + } + const redirectUris = (providerRuntimeConfig.redirectUris.length ? providerRuntimeConfig.redirectUris : [requestURL.pathname]) + .map(uri => new URL(`${redirectURL.protocol}//${redirectURL.host}${uri}`).toString()) as [string, ...string[]] + + const clientId = dev + // For local development, Bluesky authorization servers allow "http://localhost" as a special value for the client + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectURL.toString())}&scope=${encodeURIComponent(scope)}` + : `${baseUrl}/${getClientMetadataFilename('bluesky', providerRuntimeConfig)}` + + const clientMetadata: OAuthClientMetadataInput = { + client_name: providerRuntimeConfig.clientName || undefined, + client_uri: providerRuntimeConfig.clientUri || undefined, + logo_uri: providerRuntimeConfig.logoUri || undefined, + policy_uri: providerRuntimeConfig.policyUri || undefined, + tos_uri: providerRuntimeConfig.tosUri || undefined, + client_id: clientId, + redirect_uris: redirectUris, + scope, + grant_types: grantTypes, + application_type: providerRuntimeConfig.applicationType, + token_endpoint_auth_method: providerRuntimeConfig.tokenEndpointAuthMethod, + dpop_bound_access_tokens: true, + } + + return clientMetadata +} diff --git a/src/runtime/types/atproto.ts b/src/runtime/types/atproto.ts new file mode 100644 index 0000000..47604bd --- /dev/null +++ b/src/runtime/types/atproto.ts @@ -0,0 +1,77 @@ +import type { OAuthClientMetadata } from '@atproto/oauth-client-node' + +export interface AtprotoProviderClientMetadata { + /** + * The name of the client metadata file. This is used to construct the client ID. + * @example 'client-metadata.json' + * @example 'bluesky/client-metadata.json' + */ + clientMetadataFilename: string + + /** + * The human-readable name of the client. + */ + clientName?: OAuthClientMetadata['client_name'] + + /** + * Not to be confused with client_id, this is a homepage URL for the client. If provided, the client_uri must have the same hostname as client_id. + */ + clientUri?: OAuthClientMetadata['client_uri'] + + /** + * The client's logo URL. + */ + logoUri?: OAuthClientMetadata['logo_uri'] + + /** + * URL to human-readable terms of service (ToS) for the client. Only https: URIs are allowed. + */ + tosUri?: OAuthClientMetadata['tos_uri'] + + /** + * URL to human-readable privacy policy for the client. Only https: URIs are allowed. + */ + policyUri?: OAuthClientMetadata['policy_uri'] + + /** + * Must be one of web or native, with web as the default if not specified. Note that this is field specified by OpenID/OIDC, which we are borrowing. Used by the Authorization Server to enforce the relevant "best current practices". + * @default 'web' + */ + applicationType?: OAuthClientMetadata['application_type'] + + /** + * `authorization_code` must always be included and will be added if missing. `refresh_token` is optional, but must be included if the client will make token refresh requests. + * @default ['authorization_code'] + */ + grantTypes?: OAuthClientMetadata['grant_types'] + + /** + * All scope values which might be requested by this client are declared here. The atproto scope is required, and will be added if missing. + * @default ['atproto'] + */ + scope?: NonNullable[] + + /** + * `code` must always be included and will be added if missing. + */ + responseTypes?: OAuthClientMetadata['response_types'] + + /** + * At least one redirect URI is required. The URL origin must match that of the `clientId`, so declare only the path. + * @example ['/auth/callback'] + */ + redirectUris: OAuthClientMetadata['redirect_uris'] + + /** + * The token endpoint authentication method. `none` is the default, and the only supported value at this time. + * @default 'none' + */ + tokenEndpointAuthMethod?: OAuthClientMetadata['token_endpoint_auth_method'] + + /** + * DPoP is mandatory for all clients, so this must be present and true. + */ + dpopBoundAccessTokens?: OAuthClientMetadata['dpop_bound_access_tokens'] + + // @todo: add support for JWKS +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 59d4c41..dfd3639 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -1,6 +1,6 @@ import type { H3Event, H3Error } from 'h3' -export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) +export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'bluesky' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void diff --git a/src/utils/atproto.ts b/src/utils/atproto.ts new file mode 100644 index 0000000..e81c584 --- /dev/null +++ b/src/utils/atproto.ts @@ -0,0 +1,24 @@ +import type { OAuthProvider } from '../runtime/types' +import type { AtprotoProviderClientMetadata } from '../runtime/types/atproto' + +export const atprotoProviders: readonly OAuthProvider[] = ['bluesky'] as const + +export const atprotoProviderDefaultClientMetadata: AtprotoProviderClientMetadata = { + clientMetadataFilename: '', + clientName: '', + clientUri: '', + logoUri: '', + policyUri: '', + tosUri: '', + scope: ['atproto'], + grantTypes: ['authorization_code'], + responseTypes: ['code'], + applicationType: 'web', + redirectUris: [] as unknown as [string, ...string[]], + dpopBoundAccessTokens: true as const, + tokenEndpointAuthMethod: 'none', +} + +export function getClientMetadataFilename(provider: OAuthProvider, config?: AtprotoProviderClientMetadata): string { + return config?.clientMetadataFilename || provider + '/client-metadata.json' +}