From 8a7ad7c9ee489e242d2de0329d8e988dcbbfeb9a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 00:29:12 +0100 Subject: [PATCH 01/12] feat: add bluesky as a provider --- package.json | 4 +- pnpm-lock.yaml | 412 +++++++++++++++++++++++- src/module.ts | 6 + src/runtime/server/lib/oauth/bluesky.ts | 173 ++++++++++ src/runtime/types/oauth-config.ts | 2 +- 5 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 src/runtime/server/lib/oauth/bluesky.ts diff --git a/package.json b/package.json index 0fcab76..ee8abe1 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": { 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..4b8518c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -227,6 +227,12 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + // Bluesky OAuth + runtimeConfig.oauth.bluesky = defu(runtimeConfig.oauth.bluesky, { + publicUrl: '', + redirectURL: '', + scope: [] as string[], + }) // 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..abede98 --- /dev/null +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -0,0 +1,173 @@ +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 { defu } from 'defu' +import { Agent } from '@atproto/api' +import type { AppBskyActorGetProfile } from '@atproto/api' +import { handleMissingConfiguration } from '../utils' +import type { OAuthConfig } from '#auth-utils' +import { useRuntimeConfig, useStorage } from '#imports' + +export interface OAuthBlueskyConfig { + publicUrl?: string + redirectURL: string + scope: string[] + clientName?: string + clientUri?: string +} + +type BlueSkyUser = AppBskyActorGetProfile.Response['data'] | null +type BlueSkyTokens = NodeSavedSession['tokenSet'] + +export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.bluesky, { + scope: [], + }) as OAuthBlueskyConfig + + // atproto is a required scope + if (!config.scope.includes('atproto')) { + config.scope.push('atproto') + } + + const scope = config.scope.join(' ') + + // For local development, Bluesky authorization servers allow "http://localhost" as a special value + const dev = import.meta.dev + + if (!config.redirectURL || (!dev && !config.publicUrl)) { + const requiredFields = ['redirectURL', dev && 'publicUrl'].filter(Boolean) as string[] + return handleMissingConfiguration(event, 'bluesky', requiredFields, onError) + } + + 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 + clientMetadata: { + client_name: config?.clientName, + client_uri: config?.clientUri, + client_id: dev + ? `http://localhost?redirect_uri=${encodeURIComponent(config.redirectURL)}&scope=${encodeURIComponent(scope)}` + : `${config?.publicUrl}/client-metadata.json`, + redirect_uris: [config.redirectURL], + scope, + grant_types: ['authorization_code', 'refresh_token'], + application_type: 'web', + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, + }, + }) + + const query = getQuery(event) + + if (!query.code) { + try { + const handle = query.handle?.toString() + if (!handle) throw createError({ + statusCode: 400, + message: 'Missing Bluesky handle', + }) + + const url = await client.authorize(handle, { + 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 = scope.includes('transition:generic') ? (await new Agent(session).getProfile({ actor: session.did })).data : null + + return onSuccess(event, { + user: profile, + 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/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 From 69881361f8c680e3213b54310832f8d9c1f44131 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 00:29:23 +0100 Subject: [PATCH 02/12] docs: update docs to use pnpm --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2294349..2947684 100644 --- a/README.md +++ b/README.md @@ -638,26 +638,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 ``` From 3925dd65ca75c707249a10434a7348457be2867b Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 00:30:10 +0100 Subject: [PATCH 03/12] feat(playground): add bluesky to playground --- playground/app.vue | 16 ++++++++++++++++ playground/auth.d.ts | 1 + playground/server/routes/auth/bluesky.get.ts | 12 ++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 playground/server/routes/auth/bluesky.get.ts 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..526bcda --- /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, '/') + }, +}) From 0910c3a2ea94269aeae935eebf766369307d05fa Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 00:30:46 +0100 Subject: [PATCH 04/12] playground(to revert after tests): configure bluesky provider --- playground/nuxt.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 5bbe13f..ea2fc05 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -6,6 +6,14 @@ export default defineNuxtConfig({ autoImport: true, }, devtools: { enabled: true }, + runtimeConfig: { + oauth: { + bluesky: { + redirectURL: 'http://127.0.0.1:3000/auth/bluesky', + scope: ['transition:generic'], + }, + }, + }, routeRules: { '/': { // prerender: true, From 612bdb7fdaa9062c3305cafc3cf6a1e65582c403 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 01:18:21 +0100 Subject: [PATCH 05/12] chore: formatting and documentation --- README.md | 1 + src/runtime/server/lib/oauth/bluesky.ts | 58 +++++++++++++++++-------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2947684..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 diff --git a/src/runtime/server/lib/oauth/bluesky.ts b/src/runtime/server/lib/oauth/bluesky.ts index abede98..a535836 100644 --- a/src/runtime/server/lib/oauth/bluesky.ts +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -11,15 +11,36 @@ import type { import { defu } from 'defu' import { Agent } from '@atproto/api' import type { AppBskyActorGetProfile } from '@atproto/api' -import { handleMissingConfiguration } from '../utils' +import { getOAuthRedirectURL, handleMissingConfiguration } from '../utils' import type { OAuthConfig } from '#auth-utils' import { useRuntimeConfig, useStorage } from '#imports' export interface OAuthBlueskyConfig { + /** + * The URL on which your app will be deployed. This will be used to enable Bluesky to validate the client. + * This is only required for production environments. + * @default process.env.NUXT_OAUTH_BLUESKY_PUBLIC_URL + */ publicUrl?: string - redirectURL: string - scope: string[] + /** + * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_BLUESKY_REDIRECT_URL + */ + 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[] + /** + * Human-readable name of the client. + */ clientName?: string + /** + * This is a homepage URL for the client. If provided, the client_uri must have the same hostname as client_id. + */ clientUri?: string } @@ -28,12 +49,11 @@ type BlueSkyTokens = NodeSavedSession['tokenSet'] export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { - config = defu(config, useRuntimeConfig(event).oauth?.bluesky, { - scope: [], - }) as OAuthBlueskyConfig + config = defu(config, useRuntimeConfig(event).oauth?.bluesky) as OAuthBlueskyConfig - // atproto is a required scope + config.scope ||= [] if (!config.scope.includes('atproto')) { + // atproto is a required scope config.scope.push('atproto') } @@ -42,11 +62,13 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O // For local development, Bluesky authorization servers allow "http://localhost" as a special value const dev = import.meta.dev - if (!config.redirectURL || (!dev && !config.publicUrl)) { - const requiredFields = ['redirectURL', dev && 'publicUrl'].filter(Boolean) as string[] + if ((!dev && !config.publicUrl)) { + const requiredFields = [!dev && 'publicUrl'].filter(Boolean) as string[] return handleMissingConfiguration(event, 'bluesky', requiredFields, onError) } + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + const storage = useStorage() const sessionStore = new SessionStore(storage) const stateStore = new StateStore(storage) @@ -55,13 +77,14 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O 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: { client_name: config?.clientName, client_uri: config?.clientUri, client_id: dev - ? `http://localhost?redirect_uri=${encodeURIComponent(config.redirectURL)}&scope=${encodeURIComponent(scope)}` - : `${config?.publicUrl}/client-metadata.json`, - redirect_uris: [config.redirectURL], + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectURL)}&scope=${encodeURIComponent(scope)}` + : `${config.publicUrl}/client-metadata.json`, + redirect_uris: [redirectURL], scope, grant_types: ['authorization_code', 'refresh_token'], application_type: 'web', @@ -77,12 +100,10 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O const handle = query.handle?.toString() if (!handle) throw createError({ statusCode: 400, - message: 'Missing Bluesky handle', + message: 'Query parameter `handle` empty or missing. Please provide a valid Bluesky handle.', }) - const url = await client.authorize(handle, { - scope, - }) + const url = await client.authorize(handle, { scope }) return sendRedirect(event, url.toString()) } catch (err) { @@ -114,7 +135,9 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O try { const { session } = await client.callback(new URLSearchParams(query as Record)) const sessionInfo = await sessionStore.get(session.did) - const profile = scope.includes('transition:generic') ? (await new Agent(session).getProfile({ actor: session.did })).data : null + const profile = scope.includes('transition:generic') + ? (await new Agent(session).getProfile({ actor: session.did })).data + : null return onSuccess(event, { user: profile, @@ -137,6 +160,7 @@ 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 From 58ac9513dcbf4be5a717c889c2c63b1c39ff3c3d Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 02:20:18 +0100 Subject: [PATCH 06/12] fix: redirect url for local development --- src/runtime/server/lib/oauth/bluesky.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/runtime/server/lib/oauth/bluesky.ts b/src/runtime/server/lib/oauth/bluesky.ts index a535836..817befa 100644 --- a/src/runtime/server/lib/oauth/bluesky.ts +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -67,7 +67,11 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O return handleMissingConfiguration(event, 'bluesky', requiredFields, onError) } - const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + const redirectURL = new URL(config.redirectURL || getOAuthRedirectURL(event)) + if (redirectURL.hostname === 'localhost' && dev) { + // For local development, Bluesky authorization servers allow "http://127.0.0.1" as a special value + redirectURL.hostname = '127.0.0.1' + } const storage = useStorage() const sessionStore = new SessionStore(storage) @@ -82,9 +86,9 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O client_name: config?.clientName, client_uri: config?.clientUri, client_id: dev - ? `http://localhost?redirect_uri=${encodeURIComponent(redirectURL)}&scope=${encodeURIComponent(scope)}` + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectURL.toString())}&scope=${encodeURIComponent(scope)}` : `${config.publicUrl}/client-metadata.json`, - redirect_uris: [redirectURL], + redirect_uris: [redirectURL.toString()], scope, grant_types: ['authorization_code', 'refresh_token'], application_type: 'web', @@ -135,7 +139,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O try { const { session } = await client.callback(new URLSearchParams(query as Record)) const sessionInfo = await sessionStore.get(session.did) - const profile = scope.includes('transition:generic') + const profile = config.scope.includes('transition:generic') ? (await new Agent(session).getProfile({ actor: session.did })).data : null From 1c1fcaf02ee6b482e0d9994fed41d1aa5131b884 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 14:39:08 +0100 Subject: [PATCH 07/12] refactor: make atproto provider as generic as possible --- package.json | 6 ++ src/module.ts | 35 ++++++++-- src/runtime/server/lib/oauth/bluesky.ts | 90 ++++++++++++------------- src/runtime/types/atproto.ts | 77 +++++++++++++++++++++ src/utils/atproto.ts | 20 ++++++ 5 files changed, 177 insertions(+), 51 deletions(-) create mode 100644 src/runtime/types/atproto.ts create mode 100644 src/utils/atproto.ts diff --git a/package.json b/package.json index ee8abe1..86774f5 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,12 @@ }, "@simplewebauthn/server": { "optional": true + }, + "@atproto/oauth-client-node": { + "optional": true + }, + "@atproto/api": { + "optional": true } }, "devDependencies": { diff --git a/src/module.ts b/src/module.ts index 4b8518c..b67d1ab 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 } 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,12 +249,13 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) - // Bluesky OAuth - runtimeConfig.oauth.bluesky = defu(runtimeConfig.oauth.bluesky, { - publicUrl: '', - redirectURL: '', - scope: [] as string[], - }) + + // 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 + } + // 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 index 817befa..13b3c11 100644 --- a/src/runtime/server/lib/oauth/bluesky.ts +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import { createError, eventHandler, getQuery, sendRedirect } from 'h3' +import { createError, eventHandler, getQuery, getRequestURL, sendRedirect } from 'h3' import type { Storage, StorageValue } from 'unstorage' import { NodeOAuthClient, OAuthCallbackError, OAuthResolverError, OAuthResponseError } from '@atproto/oauth-client-node' import type { @@ -7,26 +7,22 @@ import type { NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore, + OAuthGrantType, } from '@atproto/oauth-client-node' -import { defu } from 'defu' import { Agent } from '@atproto/api' import type { AppBskyActorGetProfile } from '@atproto/api' -import { getOAuthRedirectURL, handleMissingConfiguration } from '../utils' +import { getOAuthRedirectURL } from '../utils' import type { OAuthConfig } from '#auth-utils' import { useRuntimeConfig, useStorage } from '#imports' +import type { AtprotoProviderClientMetadata } from '~/src/runtime/types/atproto' export interface OAuthBlueskyConfig { /** - * The URL on which your app will be deployed. This will be used to enable Bluesky to validate the client. - * This is only required for production environments. - * @default process.env.NUXT_OAUTH_BLUESKY_PUBLIC_URL - */ - publicUrl?: string - /** - * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * 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 + redirectUrl?: string /** * Bluesky OAuth Scope. The `atproto` scope is required and will be added if not present. * @default ['atproto'] @@ -34,14 +30,6 @@ export interface OAuthBlueskyConfig { * @example ['atproto', 'transition:generic'] */ scope?: string[] - /** - * Human-readable name of the client. - */ - clientName?: string - /** - * This is a homepage URL for the client. If provided, the client_uri must have the same hostname as client_id. - */ - clientUri?: string } type BlueSkyUser = AppBskyActorGetProfile.Response['data'] | null @@ -49,29 +37,40 @@ type BlueSkyTokens = NodeSavedSession['tokenSet'] export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { - config = defu(config, useRuntimeConfig(event).oauth?.bluesky) as OAuthBlueskyConfig + const blueskyRuntimeConfig = useRuntimeConfig(event).oauth.bluesky as AtprotoProviderClientMetadata - config.scope ||= [] - if (!config.scope.includes('atproto')) { - // atproto is a required scope - config.scope.push('atproto') - } + const scopes = [...new Set(['atproto', ...config?.scope ?? [], ...blueskyRuntimeConfig.scope ?? []])] + const scope = scopes.join(' ') - const scope = config.scope.join(' ') + const grantTypes = [...new Set(['authorization_code', ...blueskyRuntimeConfig.grantTypes ?? []])] as [OAuthGrantType, ...OAuthGrantType[]] - // For local development, Bluesky authorization servers allow "http://localhost" as a special value - const dev = import.meta.dev + const requestURL = getRequestURL(event) + const baseUrl = `${requestURL.protocol}//${requestURL.host}` - if ((!dev && !config.publicUrl)) { - const requiredFields = [!dev && 'publicUrl'].filter(Boolean) as string[] - return handleMissingConfiguration(event, 'bluesky', requiredFields, onError) - } + /** + * 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) + || (blueskyRuntimeConfig.redirectUris[0] && baseUrl + blueskyRuntimeConfig.redirectUris[0]) + || getOAuthRedirectURL(event), + ) - const redirectURL = new URL(config.redirectURL || getOAuthRedirectURL(event)) - if (redirectURL.hostname === 'localhost' && dev) { - // For local development, Bluesky authorization servers allow "http://127.0.0.1" as a special value + 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 = (blueskyRuntimeConfig.redirectUris.length ? blueskyRuntimeConfig.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}/${blueskyRuntimeConfig.clientMetadataFilename}` const storage = useStorage() const sessionStore = new SessionStore(storage) @@ -83,16 +82,17 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O // 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: { - client_name: config?.clientName, - client_uri: config?.clientUri, - client_id: dev - ? `http://localhost?redirect_uri=${encodeURIComponent(redirectURL.toString())}&scope=${encodeURIComponent(scope)}` - : `${config.publicUrl}/client-metadata.json`, - redirect_uris: [redirectURL.toString()], + client_name: blueskyRuntimeConfig.clientName || undefined, + client_uri: blueskyRuntimeConfig.clientUri || undefined, + logo_uri: blueskyRuntimeConfig.logoUri || undefined, + policy_uri: blueskyRuntimeConfig.policyUri || undefined, + tos_uri: blueskyRuntimeConfig.tosUri || undefined, + client_id: clientId, + redirect_uris: redirectUris, scope, - grant_types: ['authorization_code', 'refresh_token'], - application_type: 'web', - token_endpoint_auth_method: 'none', + grant_types: grantTypes, + application_type: blueskyRuntimeConfig.applicationType, + token_endpoint_auth_method: blueskyRuntimeConfig.tokenEndpointAuthMethod, dpop_bound_access_tokens: true, }, }) @@ -139,7 +139,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O try { const { session } = await client.callback(new URLSearchParams(query as Record)) const sessionInfo = await sessionStore.get(session.did) - const profile = config.scope.includes('transition:generic') + const profile = scopes.includes('transition:generic') ? (await new Agent(session).getProfile({ actor: session.did })).data : null 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/utils/atproto.ts b/src/utils/atproto.ts new file mode 100644 index 0000000..ab614a7 --- /dev/null +++ b/src/utils/atproto.ts @@ -0,0 +1,20 @@ +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', +} From 87dee8e62851cea97f113acda8eac12fd54de6cd Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 14 Nov 2024 14:45:58 +0100 Subject: [PATCH 08/12] fix: default value for client-metadata filename --- src/module.ts | 4 ++-- src/runtime/server/lib/oauth/bluesky.ts | 2 +- src/utils/atproto.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/module.ts b/src/module.ts index b67d1ab..797855c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -15,7 +15,7 @@ import { defu } from 'defu' import { randomUUID } from 'uncrypto' import type { ScryptConfig } from '@adonisjs/hash/types' import type { SessionConfig } from 'h3' -import { atProtoProviderDefaultClientMetadata, atprotoProviders } from './utils/atproto' +import { atprotoProviderDefaultClientMetadata, atprotoProviders } from './utils/atproto' import type { AtprotoProviderClientMetadata } from './runtime/types/atproto' // Module options TypeScript interface definition @@ -253,7 +253,7 @@ export default defineNuxtModule({ // 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 + runtimeConfig.oauth[provider] = defu(runtimeConfig.oauth[provider], atprotoProviderDefaultClientMetadata) as AtprotoProviderClientMetadata } // Keycloak OAuth diff --git a/src/runtime/server/lib/oauth/bluesky.ts b/src/runtime/server/lib/oauth/bluesky.ts index 13b3c11..3b0678c 100644 --- a/src/runtime/server/lib/oauth/bluesky.ts +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -70,7 +70,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O 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}/${blueskyRuntimeConfig.clientMetadataFilename}` + : `${baseUrl}/${blueskyRuntimeConfig.clientMetadataFilename || 'bluesky/client-metadata.json'}` const storage = useStorage() const sessionStore = new SessionStore(storage) diff --git a/src/utils/atproto.ts b/src/utils/atproto.ts index ab614a7..3e5b9a5 100644 --- a/src/utils/atproto.ts +++ b/src/utils/atproto.ts @@ -3,7 +3,7 @@ import type { AtprotoProviderClientMetadata } from '../runtime/types/atproto' export const atprotoProviders: readonly OAuthProvider[] = ['bluesky'] as const -export const atProtoProviderDefaultClientMetadata: AtprotoProviderClientMetadata = { +export const atprotoProviderDefaultClientMetadata: AtprotoProviderClientMetadata = { clientMetadataFilename: '', clientName: '', clientUri: '', From 7e1443e83be5ab830a4534bd480d0901b4dd0698 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 18 Nov 2024 10:26:19 +0100 Subject: [PATCH 09/12] feat: serve client metadata dynamically --- src/module.ts | 5 ++ src/runtime/server/lib/oauth/bluesky.ts | 61 ++---------------- .../atproto/client-metadata.json.get.ts | 23 +++++++ src/runtime/server/utils/atproto.ts | 64 +++++++++++++++++++ 4 files changed, 99 insertions(+), 54 deletions(-) create mode 100644 src/runtime/server/routes/atproto/client-metadata.json.get.ts create mode 100644 src/runtime/server/utils/atproto.ts diff --git a/src/module.ts b/src/module.ts index 797855c..b40640b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -254,6 +254,11 @@ export default defineNuxtModule({ 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: '/' + (runtimeConfig.oauth[provider] as AtprotoProviderClientMetadata).clientMetadataFilename, + method: 'get', + }) } // Keycloak OAuth diff --git a/src/runtime/server/lib/oauth/bluesky.ts b/src/runtime/server/lib/oauth/bluesky.ts index 3b0678c..b415f1c 100644 --- a/src/runtime/server/lib/oauth/bluesky.ts +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import { createError, eventHandler, getQuery, getRequestURL, sendRedirect } 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 { @@ -7,14 +7,12 @@ import type { NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore, - OAuthGrantType, } from '@atproto/oauth-client-node' import { Agent } from '@atproto/api' import type { AppBskyActorGetProfile } from '@atproto/api' -import { getOAuthRedirectURL } from '../utils' +import { getAtprotoClientMetadata } from '../../utils/atproto' import type { OAuthConfig } from '#auth-utils' -import { useRuntimeConfig, useStorage } from '#imports' -import type { AtprotoProviderClientMetadata } from '~/src/runtime/types/atproto' +import { useStorage } from '#imports' export interface OAuthBlueskyConfig { /** @@ -37,40 +35,8 @@ type BlueSkyTokens = NodeSavedSession['tokenSet'] export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { - const blueskyRuntimeConfig = useRuntimeConfig(event).oauth.bluesky as AtprotoProviderClientMetadata - - const scopes = [...new Set(['atproto', ...config?.scope ?? [], ...blueskyRuntimeConfig.scope ?? []])] - const scope = scopes.join(' ') - - const grantTypes = [...new Set(['authorization_code', ...blueskyRuntimeConfig.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) - || (blueskyRuntimeConfig.redirectUris[0] && baseUrl + blueskyRuntimeConfig.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 = (blueskyRuntimeConfig.redirectUris.length ? blueskyRuntimeConfig.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}/${blueskyRuntimeConfig.clientMetadataFilename || 'bluesky/client-metadata.json'}` + const clientMetadata = getAtprotoClientMetadata(event, 'bluesky', config) + const scopes = clientMetadata.scope?.split(' ') ?? [] const storage = useStorage() const sessionStore = new SessionStore(storage) @@ -81,20 +47,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O 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: { - client_name: blueskyRuntimeConfig.clientName || undefined, - client_uri: blueskyRuntimeConfig.clientUri || undefined, - logo_uri: blueskyRuntimeConfig.logoUri || undefined, - policy_uri: blueskyRuntimeConfig.policyUri || undefined, - tos_uri: blueskyRuntimeConfig.tosUri || undefined, - client_id: clientId, - redirect_uris: redirectUris, - scope, - grant_types: grantTypes, - application_type: blueskyRuntimeConfig.applicationType, - token_endpoint_auth_method: blueskyRuntimeConfig.tokenEndpointAuthMethod, - dpop_bound_access_tokens: true, - }, + clientMetadata: clientMetadata, }) const query = getQuery(event) @@ -107,7 +60,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O message: 'Query parameter `handle` empty or missing. Please provide a valid Bluesky handle.', }) - const url = await client.authorize(handle, { scope }) + const url = await client.authorize(handle, { scope: clientMetadata.scope }) return sendRedirect(event, url.toString()) } catch (err) { 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..09cdc27 --- /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 { useRuntimeConfig } from '#imports' +import { atprotoProviders } from '~/src/utils/atproto' +import type { AtprotoProviderClientMetadata } from '~/src/runtime/types/atproto' + +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 (config.clientMetadataFilename === 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..d8ee921 --- /dev/null +++ b/src/runtime/server/utils/atproto.ts @@ -0,0 +1,64 @@ +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 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}/${providerRuntimeConfig.clientMetadataFilename || provider + '/client-metadata.json'}` + + 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 +} From a16d57cfdb51beb6550cd1d3e77153da3431821c Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 18 Nov 2024 10:30:40 +0100 Subject: [PATCH 10/12] fix paths --- src/runtime/server/routes/atproto/client-metadata.json.get.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/routes/atproto/client-metadata.json.get.ts b/src/runtime/server/routes/atproto/client-metadata.json.get.ts index 09cdc27..5925f96 100644 --- a/src/runtime/server/routes/atproto/client-metadata.json.get.ts +++ b/src/runtime/server/routes/atproto/client-metadata.json.get.ts @@ -1,8 +1,8 @@ import { defineEventHandler, createError } from 'h3' import { getAtprotoClientMetadata } from '../../utils/atproto' +import { atprotoProviders } from '../../../../utils/atproto' +import type { AtprotoProviderClientMetadata } from '../../../types/atproto' import { useRuntimeConfig } from '#imports' -import { atprotoProviders } from '~/src/utils/atproto' -import type { AtprotoProviderClientMetadata } from '~/src/runtime/types/atproto' export default defineEventHandler((event) => { const path = event.path.slice(1) From 3a8001c2f1d37ca09583bf7adf045aa39c6bf0e4 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 18 Nov 2024 10:53:17 +0100 Subject: [PATCH 11/12] fix: use getClientMetadataFilename util where needed --- playground/server/routes/auth/bluesky.get.ts | 2 +- src/module.ts | 5 +++-- src/runtime/server/lib/oauth/bluesky.ts | 6 +++--- .../server/routes/atproto/client-metadata.json.get.ts | 4 ++-- src/runtime/server/utils/atproto.ts | 3 ++- src/utils/atproto.ts | 4 ++++ 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/playground/server/routes/auth/bluesky.get.ts b/playground/server/routes/auth/bluesky.get.ts index 526bcda..10b12a1 100644 --- a/playground/server/routes/auth/bluesky.get.ts +++ b/playground/server/routes/auth/bluesky.get.ts @@ -2,7 +2,7 @@ export default defineOAuthBlueskyEventHandler({ async onSuccess(event, { user }) { await setUserSession(event, { user: { - bluesky: user?.did, + bluesky: user.did, }, loggedInAt: Date.now(), }) diff --git a/src/module.ts b/src/module.ts index b40640b..3d3b6f0 100644 --- a/src/module.ts +++ b/src/module.ts @@ -15,7 +15,7 @@ import { defu } from 'defu' import { randomUUID } from 'uncrypto' import type { ScryptConfig } from '@adonisjs/hash/types' import type { SessionConfig } from 'h3' -import { atprotoProviderDefaultClientMetadata, atprotoProviders } from './utils/atproto' +import { atprotoProviderDefaultClientMetadata, atprotoProviders, getClientMetadataFilename } from './utils/atproto' import type { AtprotoProviderClientMetadata } from './runtime/types/atproto' // Module options TypeScript interface definition @@ -254,9 +254,10 @@ export default defineNuxtModule({ 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: '/' + (runtimeConfig.oauth[provider] as AtprotoProviderClientMetadata).clientMetadataFilename, + route: '/' + getClientMetadataFilename(provider, runtimeConfig.oauth[provider]), method: 'get', }) } diff --git a/src/runtime/server/lib/oauth/bluesky.ts b/src/runtime/server/lib/oauth/bluesky.ts index b415f1c..9b01822 100644 --- a/src/runtime/server/lib/oauth/bluesky.ts +++ b/src/runtime/server/lib/oauth/bluesky.ts @@ -9,7 +9,7 @@ import type { NodeSavedStateStore, } from '@atproto/oauth-client-node' import { Agent } from '@atproto/api' -import type { AppBskyActorGetProfile } from '@atproto/api' +import type { AppBskyActorDefs } from '@atproto/api' import { getAtprotoClientMetadata } from '../../utils/atproto' import type { OAuthConfig } from '#auth-utils' import { useStorage } from '#imports' @@ -30,7 +30,7 @@ export interface OAuthBlueskyConfig { scope?: string[] } -type BlueSkyUser = AppBskyActorGetProfile.Response['data'] | null +type BlueSkyUser = AppBskyActorDefs.ProfileViewDetailed | Pick type BlueSkyTokens = NodeSavedSession['tokenSet'] export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: OAuthConfig) { @@ -97,7 +97,7 @@ export function defineOAuthBlueskyEventHandler({ config, onSuccess, onError }: O : null return onSuccess(event, { - user: profile, + user: profile ?? { did: session.did }, tokens: sessionInfo!.tokenSet, }) } diff --git a/src/runtime/server/routes/atproto/client-metadata.json.get.ts b/src/runtime/server/routes/atproto/client-metadata.json.get.ts index 5925f96..ae68b38 100644 --- a/src/runtime/server/routes/atproto/client-metadata.json.get.ts +++ b/src/runtime/server/routes/atproto/client-metadata.json.get.ts @@ -1,6 +1,6 @@ import { defineEventHandler, createError } from 'h3' import { getAtprotoClientMetadata } from '../../utils/atproto' -import { atprotoProviders } from '../../../../utils/atproto' +import { atprotoProviders, getClientMetadataFilename } from '../../../../utils/atproto' import type { AtprotoProviderClientMetadata } from '../../../types/atproto' import { useRuntimeConfig } from '#imports' @@ -11,7 +11,7 @@ export default defineEventHandler((event) => { for (const provider of atprotoProviders) { const config: AtprotoProviderClientMetadata = runtimeConfig.oauth[provider] - if (config.clientMetadataFilename === path) { + if (getClientMetadataFilename(provider, config) === path) { return getAtprotoClientMetadata(event, provider) } } diff --git a/src/runtime/server/utils/atproto.ts b/src/runtime/server/utils/atproto.ts index d8ee921..4a472d7 100644 --- a/src/runtime/server/utils/atproto.ts +++ b/src/runtime/server/utils/atproto.ts @@ -3,6 +3,7 @@ import type { OAuthClientMetadataInput, OAuthGrantType } from '@atproto/oauth-cl 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' @@ -43,7 +44,7 @@ export function getAtprotoClientMetadata( 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}/${providerRuntimeConfig.clientMetadataFilename || provider + '/client-metadata.json'}` + : `${baseUrl}/${getClientMetadataFilename('bluesky', providerRuntimeConfig)}` const clientMetadata: OAuthClientMetadataInput = { client_name: providerRuntimeConfig.clientName || undefined, diff --git a/src/utils/atproto.ts b/src/utils/atproto.ts index 3e5b9a5..e81c584 100644 --- a/src/utils/atproto.ts +++ b/src/utils/atproto.ts @@ -18,3 +18,7 @@ export const atprotoProviderDefaultClientMetadata: AtprotoProviderClientMetadata dpopBoundAccessTokens: true as const, tokenEndpointAuthMethod: 'none', } + +export function getClientMetadataFilename(provider: OAuthProvider, config?: AtprotoProviderClientMetadata): string { + return config?.clientMetadataFilename || provider + '/client-metadata.json' +} From 1142876e39b387b33b67062ec1b002c9c992b24d Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 18 Nov 2024 17:09:51 +0100 Subject: [PATCH 12/12] chore: remove playground config --- playground/nuxt.config.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index ea2fc05..5bbe13f 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -6,14 +6,6 @@ export default defineNuxtConfig({ autoImport: true, }, devtools: { enabled: true }, - runtimeConfig: { - oauth: { - bluesky: { - redirectURL: 'http://127.0.0.1:3000/auth/bluesky', - scope: ['transition:generic'], - }, - }, - }, routeRules: { '/': { // prerender: true,