diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..7e58e7b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +#secure password, can use openssl rand --hex 32 +NUXT_SESSION_PASSWORD="" \ No newline at end of file diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue index ae779a39..e258b122 100644 --- a/app/components/AppHeader.vue +++ b/app/components/AppHeader.vue @@ -101,6 +101,8 @@ onKeyStroke(',', e => { + + diff --git a/app/components/AuthButton.vue b/app/components/AuthButton.vue new file mode 100644 index 00000000..222152ee --- /dev/null +++ b/app/components/AuthButton.vue @@ -0,0 +1,18 @@ + + + diff --git a/app/components/AuthModal.vue b/app/components/AuthModal.vue new file mode 100644 index 00000000..e24d83aa --- /dev/null +++ b/app/components/AuthModal.vue @@ -0,0 +1,191 @@ + + + diff --git a/app/composables/useAtproto.ts b/app/composables/useAtproto.ts new file mode 100644 index 00000000..2e445f03 --- /dev/null +++ b/app/composables/useAtproto.ts @@ -0,0 +1,25 @@ +import type { UserSession } from '#shared/schemas/userSession' + +/** @public */ +export async function useAtproto() { + const { + data: user, + pending, + clear, + } = await useAsyncData('user-state', async () => { + return await useRequestFetch()('/api/auth/session', { + headers: { accept: 'application/json' }, + }) + }) + + const logout = async () => { + await useRequestFetch()('/api/auth/session', { + method: 'delete', + headers: { accept: 'application/json' }, + }) + + clear() + } + + return { user, pending, logout } +} diff --git a/modules/dev.ts b/modules/dev.ts new file mode 100644 index 00000000..dcd82344 --- /dev/null +++ b/modules/dev.ts @@ -0,0 +1,28 @@ +import { defineNuxtModule, useNuxt } from 'nuxt/kit' +import { join } from 'node:path' +import { appendFileSync, existsSync, readFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' + +export default defineNuxtModule({ + meta: { + name: 'dev', + }, + setup() { + const nuxt = useNuxt() + if (nuxt.options._prepare || process.env.NUXT_SESSION_PASSWORD) { + return + } + + const envPath = join(nuxt.options.rootDir, '.env') + const hasPassword = + existsSync(envPath) && /^NUXT_SESSION_PASSWORD=/m.test(readFileSync(envPath, 'utf-8')) + + if (!hasPassword) { + console.info('Generating NUXT_SESSION_PASSWORD for development environment.') + const password = randomUUID().replace(/-/g, '') + + nuxt.options.runtimeConfig.sessionPassword = password + appendFileSync(envPath, `# generated by dev module\nNUXT_SESSION_PASSWORD=${password}\n`) + } + }, +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index 9e1bda7f..4207fa1b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -45,8 +45,18 @@ export default defineNuxtConfig({ css: ['~/assets/main.css', 'vue-data-ui/style.css'], + runtimeConfig: { + sessionPassword: '', + }, + devtools: { enabled: true }, + devServer: { + // Used with atproto oauth + // https://atproto.com/specs/oauth#localhost-client-development + host: '127.0.0.1', + }, + app: { head: { htmlAttrs: { lang: 'en-US' }, @@ -132,6 +142,14 @@ export default defineNuxtConfig({ driver: 'fsLite', base: './.cache/fetch', }, + 'oauth-atproto-state': { + driver: 'fsLite', + base: './.cache/atproto-oauth/state', + }, + 'oauth-atproto-session': { + driver: 'fsLite', + base: './.cache/atproto-oauth/session', + }, }, }, diff --git a/package.json b/package.json index fd67e006..66176f28 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "start:playwright:webserver": "NODE_ENV=test pnpm build && pnpm preview --port 5678" }, "dependencies": { + "@atproto/api": "^0.18.17", "@atproto/lex": "0.0.13", + "@atproto/oauth-client-node": "^0.3.15", "@deno/doc": "jsr:^0.189.1", "@iconify-json/simple-icons": "1.2.68", "@iconify-json/vscode-icons": "1.2.40", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2afb35..e8b29603 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,15 @@ importers: .: dependencies: + '@atproto/api': + specifier: ^0.18.17 + version: 0.18.20 '@atproto/lex': specifier: 0.0.13 version: 0.0.13 + '@atproto/oauth-client-node': + specifier: ^0.3.15 + version: 0.3.16 '@deno/doc': specifier: jsr:^0.189.1 version: '@jsr/deno__doc@0.189.1(patch_hash=24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832)' @@ -288,9 +294,23 @@ packages: '@atproto-labs/did-resolver@0.2.6': resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} + '@atproto-labs/fetch-node@0.2.0': + resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} + engines: {node: '>=18.7.0'} + '@atproto-labs/fetch@0.2.3': resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} + '@atproto-labs/handle-resolver-node@0.1.25': + resolution: {integrity: sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/handle-resolver@0.3.6': + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} + + '@atproto-labs/identity-resolver@0.3.6': + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} + '@atproto-labs/pipe@0.1.1': resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} @@ -300,9 +320,15 @@ packages: '@atproto-labs/simple-store@0.3.0': resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} + '@atproto/api@0.18.20': + resolution: {integrity: sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==} + '@atproto/common-web@0.4.14': resolution: {integrity: sha512-rMU8Q+kpyPpirUS9OqT7aOD1hxKa+diem3vc7BA0lOkj0tU6wcAxegxmbPZ8JaOsR7SSYhP/jCt/5wbT4qqkuQ==} + '@atproto/common-web@0.4.15': + resolution: {integrity: sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==} + '@atproto/common@0.5.9': resolution: {integrity: sha512-rzl8dB7ErpA/VUgCidahUtbxEph50J4g7j68bZmlwwrHlrtvTe8DjrwH5EUFEcegl9dadIhcVJ3qi0kPKEUr+g==} engines: {node: '>=18.7.0'} @@ -314,6 +340,15 @@ packages: '@atproto/did@0.3.0': resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} + '@atproto/jwk-jose@0.1.11': + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} + + '@atproto/jwk-webcrypto@0.2.0': + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} + + '@atproto/jwk@0.6.0': + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} + '@atproto/lex-builder@0.0.12': resolution: {integrity: sha512-ObWnmsbkPwjKKIX/L0JyMptmggr3gvbZKPDcpr1eSBUWyWeqqX8OfIlHYLgm5veNuO776RV05CE7BdQFQUA+9Q==} @@ -323,6 +358,9 @@ packages: '@atproto/lex-client@0.0.10': resolution: {integrity: sha512-n3g9KoY5hw7W29mcR4TrjN5qOi6JiWty7r1heqLLfYiq5TxaRx9/QBa2hbN4h1p4xxICPZoDlNtuGq8YV4U8mg==} + '@atproto/lex-data@0.0.10': + resolution: {integrity: sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==} + '@atproto/lex-data@0.0.9': resolution: {integrity: sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw==} @@ -332,6 +370,9 @@ packages: '@atproto/lex-installer@0.0.13': resolution: {integrity: sha512-Uu9JsZBBTVel8qz+wgf/M46uimPMn4Ub3hToscngELa+C9+6amHAtcArVdJgv4UsDu13TneOj3I6bdkU0luLTw==} + '@atproto/lex-json@0.0.10': + resolution: {integrity: sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==} + '@atproto/lex-json@0.0.9': resolution: {integrity: sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw==} @@ -348,6 +389,16 @@ packages: '@atproto/lexicon@0.6.1': resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==} + '@atproto/oauth-client-node@0.3.16': + resolution: {integrity: sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw==} + engines: {node: '>=18.7.0'} + + '@atproto/oauth-client@0.5.14': + resolution: {integrity: sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw==} + + '@atproto/oauth-types@0.6.2': + resolution: {integrity: sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==} + '@atproto/repo@0.8.12': resolution: {integrity: sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ==} engines: {node: '>=18.7.0'} @@ -355,6 +406,9 @@ packages: '@atproto/syntax@0.4.3': resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} @@ -4573,6 +4627,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} @@ -6053,6 +6110,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + ipx@3.1.1: resolution: {integrity: sha512-7Xnt54Dco7uYkfdAw0r2vCly3z0rSaVhEXMzPvl3FndsTVm5p26j+PO+gyinkYmcsEUvX2Rh7OGK7KzYWRu6BA==} hasBin: true @@ -6327,6 +6388,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -8454,6 +8518,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -8610,6 +8678,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@6.23.0: + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + engines: {node: '>=18.17'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -9404,10 +9476,35 @@ snapshots: '@atproto/did': 0.3.0 zod: 3.25.76 + '@atproto-labs/fetch-node@0.2.0': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + ipaddr.js: 2.3.0 + undici: 6.23.0 + '@atproto-labs/fetch@0.2.3': dependencies: '@atproto-labs/pipe': 0.1.1 + '@atproto-labs/handle-resolver-node@0.1.25': + dependencies: + '@atproto-labs/fetch-node': 0.2.0 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto/did': 0.3.0 + + '@atproto-labs/handle-resolver@0.3.6': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + zod: 3.25.76 + + '@atproto-labs/identity-resolver@0.3.6': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto-labs/pipe@0.1.1': {} '@atproto-labs/simple-store-memory@0.1.4': @@ -9417,6 +9514,17 @@ snapshots: '@atproto-labs/simple-store@0.3.0': {} + '@atproto/api@0.18.20': + dependencies: + '@atproto/common-web': 0.4.15 + '@atproto/lexicon': 0.6.1 + '@atproto/syntax': 0.4.3 + '@atproto/xrpc': 0.7.7 + await-lock: 2.2.2 + multiformats: 9.9.0 + tlds: 1.261.0 + zod: 3.25.76 + '@atproto/common-web@0.4.14': dependencies: '@atproto/lex-data': 0.0.9 @@ -9424,6 +9532,13 @@ snapshots: '@atproto/syntax': 0.4.3 zod: 3.25.76 + '@atproto/common-web@0.4.15': + dependencies: + '@atproto/lex-data': 0.0.10 + '@atproto/lex-json': 0.0.10 + '@atproto/syntax': 0.4.3 + zod: 3.25.76 + '@atproto/common@0.5.9': dependencies: '@atproto/common-web': 0.4.14 @@ -9443,6 +9558,22 @@ snapshots: dependencies: zod: 3.25.76 + '@atproto/jwk-jose@0.1.11': + dependencies: + '@atproto/jwk': 0.6.0 + jose: 5.10.0 + + '@atproto/jwk-webcrypto@0.2.0': + dependencies: + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + zod: 3.25.76 + + '@atproto/jwk@0.6.0': + dependencies: + multiformats: 9.9.0 + zod: 3.25.76 + '@atproto/lex-builder@0.0.12': dependencies: '@atproto/lex-document': 0.0.11 @@ -9463,6 +9594,13 @@ snapshots: '@atproto/lex-schema': 0.0.10 tslib: 2.8.1 + '@atproto/lex-data@0.0.10': + dependencies: + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.5 + '@atproto/lex-data@0.0.9': dependencies: multiformats: 9.9.0 @@ -9487,6 +9625,11 @@ snapshots: '@atproto/syntax': 0.4.3 tslib: 2.8.1 + '@atproto/lex-json@0.0.10': + dependencies: + '@atproto/lex-data': 0.0.10 + tslib: 2.8.1 + '@atproto/lex-json@0.0.9': dependencies: '@atproto/lex-data': 0.0.9 @@ -9529,6 +9672,40 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/oauth-client-node@0.3.16': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver-node': 0.1.25 + '@atproto-labs/simple-store': 0.3.0 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + '@atproto/jwk-webcrypto': 0.2.0 + '@atproto/oauth-client': 0.5.14 + '@atproto/oauth-types': 0.6.2 + + '@atproto/oauth-client@0.5.14': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto-labs/identity-resolver': 0.3.6 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/oauth-types': 0.6.2 + '@atproto/xrpc': 0.7.7 + core-js: 3.48.0 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-types@0.6.2': + dependencies: + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + zod: 3.25.76 + '@atproto/repo@0.8.12': dependencies: '@atproto/common': 0.5.9 @@ -9545,6 +9722,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@atproto/xrpc@0.7.7': + dependencies: + '@atproto/lexicon': 0.6.1 + zod: 3.25.76 + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -14137,6 +14319,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + await-lock@2.2.2: {} + axe-core@4.11.1: {} b4a@1.7.3: {} @@ -15934,6 +16118,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} + ipx@3.1.1(db0@0.3.4(better-sqlite3@12.6.2))(ioredis@5.9.2): dependencies: '@fastify/accept-negotiator': 2.0.1 @@ -16225,6 +16411,8 @@ snapshots: jiti@2.6.1: {} + jose@5.10.0: {} + jose@6.1.3: {} js-beautify@1.15.4: @@ -19119,6 +19307,8 @@ snapshots: tinyrainbow@3.0.3: {} + tlds@1.261.0: {} + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -19289,6 +19479,8 @@ snapshots: undici-types@7.16.0: {} + undici@6.23.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts new file mode 100644 index 00000000..d6dd07e0 --- /dev/null +++ b/server/api/auth/atproto.get.ts @@ -0,0 +1,64 @@ +import { Agent } from '@atproto/api' +import { NodeOAuthClient } from '@atproto/oauth-client-node' +import { createError, getQuery, sendRedirect } from 'h3' +import { useOAuthStorage } from '#server/utils/atproto/storage' +import { SLINGSHOT_ENDPOINT } from '#shared/utils/constants' +import type { UserSession } from '#shared/schemas/userSession' + +export default defineEventHandler(async event => { + const config = useRuntimeConfig(event) + if (!config.sessionPassword) { + throw createError({ + status: 500, + message: 'NUXT_SESSION_PASSWORD not set', + }) + } + + const query = getQuery(event) + const clientMetadata = getOauthClientMetadata() + const { stateStore, sessionStore } = useOAuthStorage(event) + + const atclient = new NodeOAuthClient({ + stateStore, + sessionStore, + clientMetadata, + }) + + if (!query.code) { + const handle = query.handle?.toString() + const create = query.create?.toString() + + if (!handle) { + throw createError({ + status: 400, + message: 'Handle not provided in query', + }) + } + + const redirectUrl = await atclient.authorize(handle, { + scope, + prompt: create ? 'create' : undefined, + }) + return sendRedirect(event, redirectUrl.toString()) + } + + const { session: authSession } = await atclient.callback( + new URLSearchParams(query as Record), + ) + const agent = new Agent(authSession) + event.context.agent = agent + + const session = await useSession(event, { + password: config.sessionPassword, + }) + + const response = await fetch( + `${SLINGSHOT_ENDPOINT}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${agent.did}`, + { headers: { 'User-Agent': 'npmx' } }, + ) + const miniDoc = (await response.json()) as UserSession + + await session.update(miniDoc) + + return sendRedirect(event, '/') +}) diff --git a/server/api/auth/session.delete.ts b/server/api/auth/session.delete.ts new file mode 100644 index 00000000..1b3ff676 --- /dev/null +++ b/server/api/auth/session.delete.ts @@ -0,0 +1,6 @@ +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + await oAuthSession?.signOut() + await serverSession.clear() + + return 'Session cleared' +}) diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts new file mode 100644 index 00000000..64903ca7 --- /dev/null +++ b/server/api/auth/session.get.ts @@ -0,0 +1,11 @@ +import { UserSessionSchema } from '#shared/schemas/userSession' +import { safeParse } from 'valibot' + +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + const result = safeParse(UserSessionSchema, serverSession.data) + if (!result.success) { + return null + } + + return result.output +}) diff --git a/server/routes/oauth-client-metadata.json.get.ts b/server/routes/oauth-client-metadata.json.get.ts new file mode 100644 index 00000000..0ea235ed --- /dev/null +++ b/server/routes/oauth-client-metadata.json.get.ts @@ -0,0 +1,3 @@ +export default defineEventHandler(() => { + return getOauthClientMetadata() +}) diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts new file mode 100644 index 00000000..f96ab4b2 --- /dev/null +++ b/server/utils/atproto/oauth.ts @@ -0,0 +1,82 @@ +import type { OAuthClientMetadataInput } from '@atproto/oauth-client-node' +import type { EventHandlerRequest, H3Event } from 'h3' +import type { OAuthSession } from '@atproto/oauth-client-node' +import { NodeOAuthClient } from '@atproto/oauth-client-node' +import { parse } from 'valibot' +import { useOAuthStorage } from '#server/utils/atproto/storage' +import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants' +import { OAuthMetadataSchema } from '#shared/schemas/oauth' +import type { SessionManager } from 'h3' +// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes +export const scope = 'atproto' + +export function getOauthClientMetadata() { + const dev = import.meta.dev + + // on dev, match in nuxt.config.ts devServer: { host: "127.0.0.1" } + const client_uri = dev ? `http://127.0.0.1:3000` : 'https://npmx.dev' + const redirect_uri = `${client_uri}/api/auth/atproto` + + const client_id = dev + ? `http://localhost?redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${encodeURIComponent(scope)}` + : `${client_uri}/oauth-client-metadata.json` + + // If anything changes here, please make sure to also update /shared/schemas/oauth.ts to match + return parse(OAuthMetadataSchema, { + client_name: 'npmx.dev', + client_id, + client_uri, + scope, + redirect_uris: [redirect_uri] as [string, ...string[]], + grant_types: ['authorization_code', 'refresh_token'], + application_type: 'web', + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, + }) as OAuthClientMetadataInput +} + +type EventHandlerWithOAuthSession = ( + event: H3Event, + session: OAuthSession | undefined, + serverSession: SessionManager, +) => Promise + +async function getOAuthSession(event: H3Event): Promise { + const clientMetadata = getOauthClientMetadata() + const { stateStore, sessionStore } = useOAuthStorage(event) + + const client = new NodeOAuthClient({ + stateStore, + sessionStore, + clientMetadata, + }) + + const currentSession = await sessionStore.get() + if (!currentSession) return undefined + + // restore using the subject + return await client.restore(currentSession.tokenSet.sub) +} + +/** @public */ +export function eventHandlerWithOAuthSession( + handler: EventHandlerWithOAuthSession, +) { + return defineEventHandler(async event => { + const config = useRuntimeConfig(event) + + if (!config.sessionPassword) { + throw createError({ + status: 500, + message: UNSET_NUXT_SESSION_PASSWORD, + }) + } + + const serverSession = await useSession(event, { + password: config.sessionPassword, + }) + + const oAuthSession = await getOAuthSession(event) + return await handler(event, oAuthSession, serverSession) + }) +} diff --git a/server/utils/atproto/storage.ts b/server/utils/atproto/storage.ts new file mode 100644 index 00000000..95a826a1 --- /dev/null +++ b/server/utils/atproto/storage.ts @@ -0,0 +1,81 @@ +import type { + NodeSavedSession, + NodeSavedSessionStore, + NodeSavedState, + NodeSavedStateStore, +} from '@atproto/oauth-client-node' +import type { H3Event } from 'h3' + +/** + * Storage key prefix for oauth state storage. + */ +export const OAUTH_STATE_CACHE_STORAGE_BASE = 'oauth-atproto-state' + +export class OAuthStateStore implements NodeSavedStateStore { + private readonly cookieKey = 'oauth:atproto:state' + private readonly storage = useStorage(OAUTH_STATE_CACHE_STORAGE_BASE) + + constructor(private event: H3Event) {} + + async get(): Promise { + const stateKey = getCookie(this.event, this.cookieKey) + if (!stateKey) return + const result = await this.storage.getItem(stateKey) + if (!result) return + return result + } + + async set(key: string, val: NodeSavedState) { + setCookie(this.event, this.cookieKey, key) + await this.storage.setItem(key, val) + } + + async del() { + let stateKey = getCookie(this.event, this.cookieKey) + deleteCookie(this.event, this.cookieKey) + if (stateKey) { + await this.storage.del(stateKey) + } + } +} + +/** + * Storage key prefix for oauth session storage. + */ +export const OAUTH_SESSION_CACHE_STORAGE_BASE = 'oauth-atproto-session' + +export class OAuthSessionStore implements NodeSavedSessionStore { + // TODO: not sure if we will support multi accounts, but if we do in the future will need to change this around + private readonly cookieKey = 'oauth:atproto:session' + private readonly storage = useStorage(OAUTH_SESSION_CACHE_STORAGE_BASE) + + constructor(private event: H3Event) {} + + async get(): Promise { + const sessionKey = getCookie(this.event, this.cookieKey) + if (!sessionKey) return + let result = await this.storage.getItem(sessionKey) + if (!result) return + return result + } + + async set(key: string, val: NodeSavedSession) { + setCookie(this.event, this.cookieKey, key) + await this.storage.setItem(key, val) + } + + async del() { + let sessionKey = getCookie(this.event, this.cookieKey) + if (sessionKey) { + await this.storage.del(sessionKey) + } + deleteCookie(this.event, this.cookieKey) + } +} + +export const useOAuthStorage = (event: H3Event) => { + return { + stateStore: new OAuthStateStore(event), + sessionStore: new OAuthSessionStore(event), + } +} diff --git a/shared/schemas/oauth.ts b/shared/schemas/oauth.ts new file mode 100644 index 00000000..f25239c6 --- /dev/null +++ b/shared/schemas/oauth.ts @@ -0,0 +1,16 @@ +import { object, string, pipe, url, array, minLength, boolean } from 'valibot' +import type { InferOutput } from 'valibot' + +export const OAuthMetadataSchema = object({ + client_id: pipe(string(), url()), + client_name: string(), + client_uri: pipe(string(), url()), + redirect_uris: pipe(array(string()), minLength(1)), + scope: string(), + grant_types: array(string()), + application_type: string(), + token_endpoint_auth_method: string(), + dpop_bound_access_tokens: boolean(), +}) + +export type OAuthMetadata = InferOutput diff --git a/shared/schemas/userSession.ts b/shared/schemas/userSession.ts new file mode 100644 index 00000000..ccbdc934 --- /dev/null +++ b/shared/schemas/userSession.ts @@ -0,0 +1,10 @@ +import { object, string, pipe, url } from 'valibot' +import type { InferOutput } from 'valibot' + +export const UserSessionSchema = object({ + did: string(), + handle: string(), + pds: pipe(string(), url()), +}) + +export type UserSession = InferOutput diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index e6dbb68d..15dc7639 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -16,9 +16,14 @@ export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size. export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!' export const ERROR_JSR_FETCH_FAILED = 'Failed to fetch package from JSR registry.' export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.' +export const UNSET_NUXT_SESSION_PASSWORD = 'NUXT_SESSION_PASSWORD not set' /** @public */ export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.' +// microcosm services +export const CONSTELLATION_ENDPOINT = 'https://constellation.microcosm.blue' +export const SLINGSHOT_ENDPOINT = 'https://slingshot.microcosm.blue' + // Theming export const ACCENT_COLORS = { rose: 'oklch(0.797 0.084 11.056)', diff --git a/shared/utils/fetch-cache-config.ts b/shared/utils/fetch-cache-config.ts index eb8ed145..4b6465cf 100644 --- a/shared/utils/fetch-cache-config.ts +++ b/shared/utils/fetch-cache-config.ts @@ -5,6 +5,8 @@ * using Nitro's storage layer (backed by Vercel's runtime cache in production). */ +import { CONSTELLATION_ENDPOINT, SLINGSHOT_ENDPOINT } from './constants' + /** * Domains that should have their fetch responses cached. * Only requests to these domains will be intercepted and cached. @@ -24,6 +26,9 @@ export const FETCH_CACHE_ALLOWED_DOMAINS = [ 'api.bitbucket.org', // Bitbucket API 'codeberg.org', // Codeberg (Gitea-based) 'gitee.com', // Gitee API + // microcosm endpoints for atproto data + CONSTELLATION_ENDPOINT, + SLINGSHOT_ENDPOINT, ] as const /**