diff --git a/.env.example b/.env.example index 172185ca..6818fb8c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ NUXT_SHOPIFY_CLIENTS_STOREFRONT_PRIVATE_ACCESS_TOKEN="YOUR_PRIVATE_ACCESS_TOKEN" # Customer Account API NUXT_SHOPIFY_CLIENTS_CUSTOMER_ACCOUNT_API_VERSION="2026-01" NUXT_SHOPIFY_CLIENTS_CUSTOMER_ACCOUNT_CLIENT_ID="YOUR_APP_CLIENT_ID" +NUXT_SHOPIFY_CLIENTS_CUSTOMER_ACCOUNT_CLIENT_SECRET="YOUR_APP_CLIENT_SECRET" +NUXT_SHOPIFY_CLIENTS_CUSTOMER_ACCOUNT_SESSION_PASSWORD="A_RANDOM_32_CHARACTER_OR_LONGER_SECRET" # Admin API NUXT_SHOPIFY_CLIENTS_ADMIN_API_VERSION="2026-01" diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index 9a895ab7..00000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -1,2 +0,0 @@ -# Eslint auto fix commit -2d623e0e2bebaa788beb8e6d6959bc8d6df0798c diff --git a/build.config.ts b/build.config.ts index d62f1941..9447be9f 100644 --- a/build.config.ts +++ b/build.config.ts @@ -20,6 +20,5 @@ export default defineBuildConfig({ externals: [ '@shopify/hydrogen', - 'nuxt-auth-utils', ], }) diff --git a/bun.lock b/bun.lock index 05736f4f..1140a963 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,6 @@ "happy-dom": "^20.10.6", "knip": "^6.17.1", "nuxt": "^4.4.8", - "nuxt-auth-utils": "^0.5.29", "typescript": "^6.0.3", "unbuild": "^3.6.1", "vite": "^8.0.16", @@ -52,11 +51,9 @@ }, "peerDependencies": { "@shopify/hydrogen": "*", - "nuxt-auth-utils": "*", }, "optionalPeers": [ "@shopify/hydrogen", - "nuxt-auth-utils", ], }, "docs": { @@ -89,7 +86,6 @@ }, "devDependencies": { "@shopify/hydrogen": "^2026.4.4", - "nuxt-auth-utils": "^0.5.29", }, }, "playgrounds/playground-v4-mock": { @@ -108,7 +104,6 @@ "@nuxtjs/shopify": "latest", "@tailwindcss/typography": "^0.5.20", "nuxt": "^4.4.8", - "nuxt-auth-utils": "^0.5.29", "zod": "^4.4.3", }, "devDependencies": { @@ -134,8 +129,6 @@ "@nuxtjs/shopify": "file:.", }, "packages": { - "@adonisjs/hash": ["@adonisjs/hash@9.1.1", "", { "dependencies": { "@phc/format": "^1.0.0", "@poppinss/utils": "^6.9.3" }, "peerDependencies": { "argon2": "^0.31.2 || ^0.41.0 || ^0.43.0", "bcrypt": "^5.1.1 || ^6.0.0" }, "optionalPeers": ["argon2", "bcrypt"] }, "sha512-ZkRguwjAp4skKvKDdRAfdJ2oqQ0N7p9l3sioyXO1E8o0WcsyDgEpsTQtuVNoIdMiw4sn4gJlmL3nyF4BcK1ZDQ=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.131", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CnjOZdywQaUnCyZ0N5wVNm7Sm63+NeHDVZQJKFX2IDq+t03SLwiiuoi3ILTLPlM+YSOhkQ/pvIDoR4qa98Zp5A=="], "@ai-sdk/mcp": ["@ai-sdk/mcp@1.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-03JEdJtB+K/I7a85GWJjrB+ypYY++ZFDTp2YJ5Wd0e0raZdFpBLvvX9LJIxPF1LRbs7PMtx6TIS1YMP0Dece+w=="], @@ -840,8 +833,6 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], - "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], @@ -852,12 +843,6 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@poppinss/object-builder": ["@poppinss/object-builder@1.1.0", "", {}, "sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg=="], - - "@poppinss/string": ["@poppinss/string@1.7.2", "", { "dependencies": { "@types/pluralize": "^0.0.33", "case-anything": "^3.1.2", "pluralize": "^8.0.0", "slugify": "^1.6.9" } }, "sha512-A182GLDfi36iDCbhDrHB0xzrPM1fO3GHnhCDIdadf8C6eycgct4m7zusbLwEh6GPaj2Pz5BVos7XK16w7tZ7wQ=="], - - "@poppinss/utils": ["@poppinss/utils@6.10.1", "", { "dependencies": { "@poppinss/exception": "^1.2.1", "@poppinss/object-builder": "^1.1.0", "@poppinss/string": "^1.3.0", "flattie": "^1.1.1", "safe-stable-stringify": "^2.5.0", "secure-json-parse": "^4.0.0" } }, "sha512-da+MMyeXhBaKtxQiWPfy7+056wk3lVIhioJnXHXkJ2/OHDaZfFcyKHNl1R06sdYO8lIRXcXdoZ6LO2ARmkAREA=="], - "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], "@react-router/dev": ["@react-router/dev@7.16.0", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@react-router/node": "7.16.0", "@remix-run/node-fetch-server": "^0.13.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "pkg-types": "^2.3.0", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", "valibot": "^1.2.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.16.0", "@vitejs/plugin-rsc": "~0.5.21", "react-router": "^7.16.0", "react-server-dom-webpack": "^19.2.3", "typescript": "^5.1.0 || ^6.0.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "react-server-dom-webpack", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-E/uNYnHbo+wepw+FudGwuN6au6y4dOfVuRRANYdCp5T+tLvGU/09s2uGzNQsxqushyae9wvWTLY0qMvvgowIyg=="], @@ -1190,8 +1175,6 @@ "@types/parse-path": ["@types/parse-path@7.1.0", "", { "dependencies": { "parse-path": "*" } }, "sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q=="], - "@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="], - "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -1510,8 +1493,6 @@ "capital-case": ["capital-case@1.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", "upper-case-first": "^2.0.2" } }, "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A=="], - "case-anything": ["case-anything@3.1.2", "", {}, "sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ=="], - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -1922,8 +1903,6 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], - "fontaine": ["fontaine@0.8.0", "", { "dependencies": { "@capsizecss/unpack": "^4.0.0", "css-tree": "^3.1.0", "magic-regexp": "^0.10.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "ufo": "^1.6.1", "unplugin": "^2.3.10" } }, "sha512-eek1GbzOdWIj9FyQH/emqW1aEdfC3lYRCHepzwlFCm5T77fBSRSyNRKE6/antF1/B1M+SfJXVRQTY9GAr7lnDg=="], "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], @@ -2546,8 +2525,6 @@ "nuxt": ["nuxt@4.4.8", "", { "dependencies": { "@dxup/nuxt": "^0.4.1", "@nuxt/cli": "^3.35.2", "@nuxt/devtools": "^3.2.4", "@nuxt/kit": "4.4.8", "@nuxt/nitro-server": "4.4.8", "@nuxt/schema": "4.4.8", "@nuxt/telemetry": "^2.8.0", "@nuxt/vite-builder": "4.4.8", "@unhead/vue": "^2.1.15", "@vue/shared": "^3.5.35", "chokidar": "^5.0.0", "compatx": "^0.2.0", "consola": "^3.4.2", "cookie-es": "^3.1.1", "defu": "^6.1.7", "devalue": "^5.8.1", "errx": "^0.1.0", "escape-string-regexp": "^5.0.0", "exsolve": "^1.0.8", "hookable": "^6.1.1", "ignore": "^7.0.5", "impound": "^1.1.5", "jiti": "^2.7.0", "klona": "^2.0.6", "knitwork": "^1.3.0", "magic-string": "^0.30.21", "mlly": "^1.8.2", "nanotar": "^0.3.0", "nypm": "^0.6.6", "ofetch": "^1.5.1", "ohash": "^2.0.11", "on-change": "^6.0.2", "oxc-minify": "^0.133.0", "oxc-parser": "^0.133.0", "oxc-transform": "^0.133.0", "oxc-walker": "^1.0.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.4", "pkg-types": "^2.3.1", "rou3": "^0.8.1", "scule": "^1.3.0", "semver": "^7.8.1", "std-env": "^4.1.0", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "ultrahtml": "^1.6.0", "uncrypto": "^0.1.3", "unctx": "^2.5.0", "unhead": "^2.1.15", "unimport": "^6.3.0", "unplugin": "^3.0.0", "unrouting": "^0.1.7", "untyped": "^2.0.0", "vue": "^3.5.35", "vue-router": "^5.1.0" }, "peerDependencies": { "@parcel/watcher": "^2.1.0", "@types/node": ">=18.12.0" }, "optionalPeers": ["@parcel/watcher", "@types/node"], "bin": { "nuxi": "bin/nuxt.mjs", "nuxt": "bin/nuxt.mjs" } }, "sha512-r/DGE4cNkEDclOw9tbJ18zqu+ix3me+7QCfumPdl5lBXGWgCajskzuq/HzDkHKfIZsn7ACVEjMLRNA2teh++bQ=="], - "nuxt-auth-utils": ["nuxt-auth-utils@0.5.29", "", { "dependencies": { "@adonisjs/hash": "^9.1.1", "@nuxt/kit": "^4.3.1", "defu": "^6.1.4", "h3": "^1.15.4", "hookable": "^6.0.1", "jose": "^6.1.3", "ofetch": "^1.5.1", "openid-client": "^6.8.2", "pathe": "^2.0.3", "scule": "^1.3.0", "uncrypto": "^0.1.3" }, "peerDependencies": { "@atproto/api": "^0.13.15", "@atproto/oauth-client-node": "^0.2.0", "@simplewebauthn/browser": "^11.0.0", "@simplewebauthn/server": "^11.0.0" }, "optionalPeers": ["@atproto/api", "@atproto/oauth-client-node", "@simplewebauthn/browser", "@simplewebauthn/server"] }, "sha512-aQ9oD8QR51jUCe2BFEsO/G/E0K+XUy8Skjn3hYcNLiqZ9XE4Y3uT/ozBCmAQ+TR3WW6prj8vUR0wQes8W1N0PA=="], - "nuxt-component-meta": ["nuxt-component-meta@0.17.2", "", { "dependencies": { "@nuxt/kit": "^4.2.2", "citty": "^0.1.6", "mlly": "^1.8.0", "ohash": "^2.0.11", "scule": "^1.3.0", "typescript": "^5.9.3", "ufo": "^1.6.2", "vue-component-meta": "^3.2.2" }, "bin": { "nuxt-component-meta": "bin/nuxt-component-meta.mjs" } }, "sha512-2/mSSqutOX8t+r8cAX1yUYwAPBqicPO5Rfum3XaHVszxKCF4tXEXBiPGfJY9Zn69x/CIeOdw+aM9wmHzQ5Q+lA=="], "nuxt-define": ["nuxt-define@1.0.0", "", {}, "sha512-CYZ2WjU+KCyCDVzjYUM4eEpMF0rkPmkpiFrybTqqQCRpUbPt2h3snswWIpFPXTi+osRCY6Og0W/XLAQgDL4FfQ=="], @@ -2570,8 +2547,6 @@ "nypm": ["nypm@0.6.7", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.2.4" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ=="], - "oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-deep-merge": ["object-deep-merge@2.0.1", "", {}, "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg=="], @@ -2598,8 +2573,6 @@ "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], - "openid-client": ["openid-client@6.8.4", "", { "dependencies": { "jose": "^6.2.2", "oauth4webapi": "^3.8.5" } }, "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], @@ -2940,8 +2913,6 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], @@ -2952,8 +2923,6 @@ "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], - "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], - "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], diff --git a/docs/content/1.getting-started/4.usage.md b/docs/content/1.getting-started/4.usage.md index b6dabdcb..dae8c947 100644 --- a/docs/content/1.getting-started/4.usage.md +++ b/docs/content/1.getting-started/4.usage.md @@ -167,7 +167,7 @@ export default defineNuxtConfig({ ``` ::note -Confidential client usage with the `clientSecret` is currently not supported for the Customer Account API. +Both public clients and confidential clients (by also providing a `clientSecret`) are supported for the Customer Account API. See the [Customer Account API guide](/essentials/customer-account) for more details on how to configure and use the Customer Account API client. :: diff --git a/docs/content/2.essentials/2.configuration.md b/docs/content/2.essentials/2.configuration.md index 1dc28828..f8d2015b 100644 --- a/docs/content/2.essentials/2.configuration.md +++ b/docs/content/2.essentials/2.configuration.md @@ -230,6 +230,10 @@ Configure one or more API clients: Client ID for customer account API requests ::: + :::field{name="clientSecret" type="string"} + Client secret for confidential clients. When set, the OAuth token exchange is authenticated with the secret instead of PKCE - server-side only, optional + ::: + :::field{name="scope" type="array"} OAuth scopes to request during authentication - default: `['openid', 'email', 'customer-account-api:full']` ::: @@ -242,10 +246,34 @@ Configure one or more API clients: Logout URL for customer account API authentication - default: `/_auth/customer-account/logout` ::: + :::field{name="sessionURL" type="string"} + Endpoint that exposes the current session to the client - default: `/_auth/customer-account/session` + ::: + :::field{name="redirectURL" type="string"} Redirect URL to navigate to after successful customer account API authentication - default: `/` ::: + :::field{name="logoutRedirectURL" type="string"} + Redirect URL to navigate to after logout. Must be a registered logout URI in your Shopify app - optional + ::: + + :::field{name="session.password" type="string"} + Password used to encrypt the session cookie (at least 32 characters). Auto-generated in development, required in production - server-side only + ::: + + :::field{name="session.name" type="string"} + Name of the session cookie - default: `shopify-customer-account` + ::: + + :::field{name="session.maxAge" type="number"} + Lifetime of the session cookie in seconds - default: `604800` (7 days) + ::: + + :::field{name="tokenStorage" type="string | object | boolean"} + [unstorage](https://unstorage.unjs.io) mount or driver configuration for the server-side token store. Use a persistent driver (e.g. Redis) in production - default: in-memory + ::: + :::field{name="dev.tunnelURL" type="string"} Tunnel URL for local development with ngrok or similar - optional ::: diff --git a/docs/content/2.essentials/4.customer-account.md b/docs/content/2.essentials/4.customer-account.md index f79f4202..a9d2f77f 100644 --- a/docs/content/2.essentials/4.customer-account.md +++ b/docs/content/2.essentials/4.customer-account.md @@ -9,11 +9,13 @@ seo: description: Using the Shopify Customer Account API in Nuxt --- -The Customer Account API is a public API that allows you to access your Shopify store's customer data, +The Customer Account API allows you to access your Shopify store's customer data, such as customer profiles, addresses, and order history. -It is designed to be used in client-side applications, such as a Nuxt frontend. To use the Customer Account API, you need to obtain a client id. -Confidential client authentication is currently not supported, so a client secret is not required. + +The module supports both **public clients** (which authenticate using PKCE) and **confidential clients**. +To use a confidential client, provide a `clientSecret` in addition to the `clientId`. The secret is only ever +used on the server and is never exposed to the browser. ::note See the [module configuration](/essentials/configuration) to see how to set up the module for the Customer Account API. @@ -29,13 +31,17 @@ you are using (e.g. `@shopify/hydrogen@2026.4.0` for API version 2026-04). npm install @shopify/hydrogen ``` -For the customer account authentication flow, also make sure to install the `nuxt-auth-utils` package: +The customer account session is split between an encrypted identity cookie and a server-side token store. +In development, a session password is generated automatically and written to your `.env` file. In production, +you must provide your own password (at least 32 characters) via the +`NUXT_SHOPIFY_CLIENTS_CUSTOMER_ACCOUNT_SESSION_PASSWORD` environment variable or the `session.password` config option. -```bash -npm install nuxt-auth-utils -``` +The access and refresh tokens never leave the server — they are kept in an [unstorage](https://unstorage.unjs.io) +mount (the `tokenStorage` option), which defaults to in-memory. For production, configure a persistent driver +(e.g. Redis) so sessions survive restarts and are shared across instances; otherwise customers will need to log in +again whenever the server restarts. -Then, once configured, you can use the Customer Account API in your Nuxt application via the `useCustomerAccount` +Once configured, you can use the Customer Account API in your Nuxt application via the `useCustomerAccount` and `useCustomerAccountData` composables. The `useCustomerAccount` composable is available to both server and client-side. ## Authentication @@ -47,62 +53,44 @@ This means that the authentication flow will not work when running the app in de To work around this, you can use a tunneling service like [ngrok](https://ngrok.com/) to create a public URL for your local development server. See also the [Dev Bridge / Tunneling](#dev-bridge-tunneling) section below for a more seamless solution to this problem. -### Logging in +### The session composable -To initiate the login flow, redirect the user to `/_auth/customer-account/callback`: +The module provides a `useCustomerAccountSession` composable to manage authentication across both server and client. +It exposes the current `user`, a reactive `isLoggedIn` flag, and `login` / `logout` actions: ```vue [~/app/pages/account.vue] ``` +Calling `login()` starts the OAuth flow, and `logout()` ends both the local session and the Shopify session. + ::tip You can change the default authentication routes in the module configuration if you want to use different URLs for the login and logout routes. :: -### Logging out - -To log the user out, redirect them to `/_auth/customer-account/logout`: +### On the server -```vue [~/app/pages/account.vue] - +Inside server routes and utilities, use the auto-imported `getCustomerAccountSession` and `requireCustomerAccountSession` +helpers to read the session. `requireCustomerAccountSession` throws a `401` error when there is no authenticated customer: - -``` - -### Checking login status - -The module uses [`nuxt-auth-utils`](https://github.com/atinux/nuxt-auth-utils) under the hood to manage the session. -You can use its composables to check whether a user is currently authenticated: - -```vue [~/app/pages/account.vue] - +```ts [~/server/api/me.ts] +export default defineEventHandler(async (event) => { + const { user } = await requireCustomerAccountSession(event) - + return user +}) ``` Once the user is authenticated, you can use the `useCustomerAccount` and `useCustomerAccountData` composables diff --git a/docs/content/4.going-further/1.hooks.md b/docs/content/4.going-further/1.hooks.md index a5cd33cf..e1033758 100644 --- a/docs/content/4.going-further/1.hooks.md +++ b/docs/content/4.going-further/1.hooks.md @@ -149,6 +149,66 @@ export default defineNitroPlugin((app) => { ``` :: +### Customer account auth hooks + +The customer account authentication flow runs entirely on the server, so these hooks are only available in a Nitro plugin. +Use them to customize the OAuth request, run side effects on login/logout, or observe token refreshes. + +```ts [~/server/plugins/customer-account-auth.ts] +export default defineNitroPlugin((app) => { + app.hooks.hook('customer-account:auth:authorize', ({ params }) => { + // Mutate the authorization request, e.g. request a localized login screen + params.locale = 'en' + }) + + app.hooks.hook('customer-account:auth:success', ({ user, tokens }) => { + // React to a successful login (merge the guest cart, track analytics, ...) + console.log('Customer logged in:', user.email) + }) + + app.hooks.hook('customer-account:auth:refresh', ({ tokens }) => { + console.log('Access token refreshed, expires at:', tokens.expiresAt) + }) + + app.hooks.hook('customer-account:auth:logout', ({ user }) => { + console.log('Customer logged out:', user?.email) + }) + + app.hooks.hook('customer-account:auth:error', ({ error }) => { + console.error('Customer account OAuth failed:', error) + }) +}) +``` + +### Admin auth hooks + +The admin API authenticates with a machine-to-machine OAuth flow (client credentials, with refresh token rotation), so these hooks are only available in a Nitro plugin. +Use them to customize the token request, or to observe when an access token is issued, refreshed, or fails to issue. + +These hooks only fire when the module obtains a token on your behalf. They are skipped when a static `accessToken` is configured. + +```ts [~/server/plugins/admin-auth.ts] +export default defineNitroPlugin((app) => { + app.hooks.hook('admin:auth:request', ({ params }) => { + // Mutate the token request body before it is sent + params.scope = 'my-custom-scope' + }) + + app.hooks.hook('admin:auth:success', ({ token }) => { + // React to a freshly issued access token (client credentials grant) + console.log('Admin access token obtained, expires at:', token.expiresAt) + }) + + app.hooks.hook('admin:auth:refresh', ({ token }) => { + console.log('Admin access token refreshed, expires at:', token.expiresAt) + }) + + app.hooks.hook('admin:auth:error', ({ error }) => { + console.error('Admin token request failed:', error) + }) +}) +``` + ## Hooks reference Hooks allows you to hook into the different stages of the module lifecycle. @@ -185,20 +245,29 @@ Hooks allows you to hook into the different stages of the module lifecycle. ### Server Hooks (Runtime) -| Hook | Arguments | Environments | Description | -| ----------------------------------- | :--------------------------------: | :----------: | ------------------------------------------------------------- | -| `storefront:client:configure` | `config` | Server | Called before the storefront client is created | -| `storefront:client:create` | `client` | Server | Called after the storefront client is created | -| `storefront:client:request` | `operation`, `options` | Server | Called before the storefront client sends a request | -| `storefront:client:response` | `response`, `operation`, `options` | Server | Called after the storefront client receives a response | -| `storefront:client:errors` | `errors` | Server | Called when a storefront client request contains errors | -| `customer-account:client:configure` | `config` | Server | Called before the customer account client is created | -| `customer-account:client:create` | `client` | Server | Called after the customer account client is created | -| `customer-account:client:request` | `operation`, `options` | Server | Called before the customer account client sends a request | -| `customer-account:client:response` | `response`, `operation`, `options` | Server | Called after the customer account client receives a response | -| `customer-account:client:errors` | `errors` | Server | Called when a customer account client request contains errors | -| `admin:client:configure` | `config` | Server | Called before the admin client is created | -| `admin:client:create` | `client` | Server | Called after the admin client is created | -| `admin:client:request` | `operation`, `options` | Server | Called before the admin client sends a request | -| `admin:client:response` | `response`, `operation`, `options` | Server | Called after the admin client receives a response | -| `admin:client:errors` | `errors` | Server | Called when an admin client request contains errors | +| Hook | Arguments | Environments | Description | +| ----------------------------------- | :--------------------------------: | :----------: | ------------------------------------------------------------------------------- | +| `storefront:client:configure` | `config` | Server | Called before the storefront client is created | +| `storefront:client:create` | `client` | Server | Called after the storefront client is created | +| `storefront:client:request` | `operation`, `options` | Server | Called before the storefront client sends a request | +| `storefront:client:response` | `response`, `operation`, `options` | Server | Called after the storefront client receives a response | +| `storefront:client:errors` | `errors` | Server | Called when a storefront client request contains errors | +| `customer-account:client:configure` | `config` | Server | Called before the customer account client is created | +| `customer-account:client:create` | `client` | Server | Called after the customer account client is created | +| `customer-account:client:request` | `operation`, `options` | Server | Called before the customer account client sends a request | +| `customer-account:client:response` | `response`, `operation`, `options` | Server | Called after the customer account client receives a response | +| `customer-account:client:errors` | `errors` | Server | Called when a customer account client request contains errors | +| `customer-account:auth:authorize` | `params` | Server | Called before redirecting to Shopify to start the OAuth flow (mutable `params`) | +| `customer-account:auth:success` | `user`, `tokens` | Server | Called after a successful login, before the session is persisted | +| `customer-account:auth:refresh` | `tokens` | Server | Called after the customer account access token is refreshed | +| `customer-account:auth:logout` | `user`, `idToken` | Server | Called before the session is cleared on logout | +| `customer-account:auth:error` | `error` | Server | Called when the customer account OAuth flow fails | +| `admin:auth:request` | `params` | Server | Called before the admin access token request is sent (mutable `params`) | +| `admin:auth:success` | `token` | Server | Called after an admin access token is obtained (client credentials grant) | +| `admin:auth:refresh` | `token` | Server | Called after the admin access token is refreshed (refresh token grant) | +| `admin:auth:error` | `error` | Server | Called when the admin access token request fails | +| `admin:client:configure` | `config` | Server | Called before the admin client is created | +| `admin:client:create` | `client` | Server | Called after the admin client is created | +| `admin:client:request` | `operation`, `options` | Server | Called before the admin client sends a request | +| `admin:client:response` | `response`, `operation`, `options` | Server | Called after the admin client receives a response | +| `admin:client:errors` | `errors` | Server | Called when an admin client request contains errors | diff --git a/docs/content/index.md b/docs/content/index.md index fa3406aa..9dfe8e43 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -133,10 +133,10 @@ CustomerAccount.vue :::code-tree{default-value="app/components/CustomerAccount.vue"} ```vue [app/components/CustomerAccount.vue] diff --git a/playgrounds/playground-v4/nuxt.config.ts b/playgrounds/playground-v4/nuxt.config.ts index c02afe0b..54f41163 100644 --- a/playgrounds/playground-v4/nuxt.config.ts +++ b/playgrounds/playground-v4/nuxt.config.ts @@ -1,7 +1,6 @@ export default defineNuxtConfig({ modules: [ '../../src/module', - 'nuxt-auth-utils', ], runtimeConfig: { diff --git a/playgrounds/playground-v4/package.json b/playgrounds/playground-v4/package.json index a90df9c5..4e6ee0e9 100644 --- a/playgrounds/playground-v4/package.json +++ b/playgrounds/playground-v4/package.json @@ -15,7 +15,6 @@ "nuxt": "^4.4.8" }, "devDependencies": { - "@shopify/hydrogen": "^2026.4.4", - "nuxt-auth-utils": "^0.5.29" + "@shopify/hydrogen": "^2026.4.4" } } diff --git a/playgrounds/playground-v4/server/plugins/server.ts b/playgrounds/playground-v4/server/plugins/server.ts index 34e5e96a..c3cb1be0 100644 --- a/playgrounds/playground-v4/server/plugins/server.ts +++ b/playgrounds/playground-v4/server/plugins/server.ts @@ -24,18 +24,54 @@ export default defineNitroPlugin((nitroApp) => { }) nitroApp.hooks.hook('admin:client:create', ({ client: _client }) => { - console.log('[shopify] storefront client created') + console.log('[shopify] admin client created') }) nitroApp.hooks.hook('admin:client:request', ({ operation: _operation, options: _options }) => { - console.log('[shopify] storefront client request sent') + console.log('[shopify] admin client request sent') }) nitroApp.hooks.hook('admin:client:response', ({ response: _response, operation: _operation, options: _options }) => { - console.log('[shopify] storefront client received response') + console.log('[shopify] admin client received response') }) nitroApp.hooks.hook('admin:client:errors', ({ errors: _errors }) => { - console.log('[shopify] storefront client received errors') + console.log('[shopify] admin client received errors') + }) + + nitroApp.hooks.hook('customer-account:auth:authorize', ({ params }) => { + params.locale = 'en' + }) + + nitroApp.hooks.hook('customer-account:auth:success', ({ user, tokens: _tokens }) => { + console.log('[shopify] customer account login', user.email) + }) + + nitroApp.hooks.hook('customer-account:auth:refresh', ({ tokens }) => { + console.log('[shopify] customer account token refreshed', tokens.expiresAt) + }) + + nitroApp.hooks.hook('customer-account:auth:logout', ({ user, idToken: _idToken }) => { + console.log('[shopify] customer account logout', user?.email) + }) + + nitroApp.hooks.hook('customer-account:auth:error', ({ error }) => { + console.error('[shopify] customer account auth error', error) + }) + + nitroApp.hooks.hook('admin:auth:request', ({ params }) => { + console.log('[shopify] admin auth request', params.grant_type) + }) + + nitroApp.hooks.hook('admin:auth:success', ({ token }) => { + console.log('[shopify] admin access token obtained', token.expiresAt) + }) + + nitroApp.hooks.hook('admin:auth:refresh', ({ token }) => { + console.log('[shopify] admin access token refreshed', token.expiresAt) + }) + + nitroApp.hooks.hook('admin:auth:error', ({ error }) => { + console.error('[shopify] admin auth error', error) }) }) diff --git a/src/module.ts b/src/module.ts index d7e8dfff..b9e54dd4 100644 --- a/src/module.ts +++ b/src/module.ts @@ -30,12 +30,6 @@ export default defineNuxtModule({ }, }, - moduleDependencies: { - 'nuxt-auth-utils': { - optional: true, - }, - }, - async setup(options, nuxt) { const runtimeConfig = useRuntimeConfig() diff --git a/src/runtime/composables/customer-account/client.ts b/src/runtime/composables/customer-account/client.ts index 5226bf5d..81024a45 100644 --- a/src/runtime/composables/customer-account/client.ts +++ b/src/runtime/composables/customer-account/client.ts @@ -2,8 +2,7 @@ import type { CustomerAccountApiClient, CustomerAccountOperations } from '@nuxtj import { joinURL } from 'ufo' -import { useRuntimeConfig, useNuxtApp, useRequestURL } from '#imports' -import { useCookie } from '#app' +import { useRuntimeConfig, useNuxtApp, useRequestURL, useRequestHeaders } from '#imports' import { createClient } from '../../utils/client' import { createCustomerAccountConfig } from '../../utils/clients/customer-account' import useErrors from '../../utils/errors' @@ -11,15 +10,18 @@ import useErrors from '../../utils/errors' export function useCustomerAccount(): CustomerAccountApiClient { const { _shopify } = useRuntimeConfig().public - const sessionCookie = useCookie('nuxt-session') - const config = createCustomerAccountConfig(_shopify) const nuxtApp = useNuxtApp() if (_shopify?.clients.customerAccount?.proxy && !nuxtApp.payload.prerenderedAt) { config.apiUrl = joinURL(useRequestURL().origin, _shopify.clients.customerAccount.proxy.path) - config.headers['Cookie'] = `nuxt-session=${sessionCookie.value}` + + if (import.meta.server) { + const cookie = useRequestHeaders(['cookie']).cookie + + if (cookie) config.headers['Cookie'] = cookie + } } nuxtApp.hooks.callHook('customer-account:client:configure', { config }) diff --git a/src/runtime/composables/customer-account/session.ts b/src/runtime/composables/customer-account/session.ts new file mode 100644 index 00000000..b5505f3e --- /dev/null +++ b/src/runtime/composables/customer-account/session.ts @@ -0,0 +1,54 @@ +import type { CustomerAccountSession, CustomerAccountUser } from '../../server/utils/customer-account/session' + +import { computed } from 'vue' +import { joinURL, withLeadingSlash } from 'ufo' +import { createError, navigateTo, useRequestFetch, useRuntimeConfig, useState } from '#imports' + +const emptySession = (): CustomerAccountSession => ({ + loggedIn: false, + user: null, + loggedInAt: null, +}) + +export function useCustomerAccountSession() { + const { _shopify } = useRuntimeConfig().public + + const customerAccount = _shopify?.clients?.customerAccount + + if (!customerAccount) { + throw createError({ statusCode: 500, message: '[shopify] Customer account client is not configured' }) + } + + const session = useState('shopify-customer-account-session', emptySession) + const ready = useState('shopify-customer-account-session-ready', () => false) + + const fetch = async () => { + session.value = await useRequestFetch()(withLeadingSlash(customerAccount.sessionURL), { + headers: { accept: 'application/json' }, + }).catch(() => emptySession()) + + ready.value = true + } + + const login = () => { + let url = withLeadingSlash(customerAccount.loginURL) + + if (import.meta.dev && customerAccount.dev.tunnelURL) { + url = joinURL(customerAccount.dev.tunnelURL, customerAccount.loginURL) + } + + return navigateTo(url, { external: true }) + } + + const logout = () => navigateTo(withLeadingSlash(customerAccount.logoutURL), { external: true }) + + return { + user: computed((): CustomerAccountUser | null => session.value.user), + isLoggedIn: computed(() => session.value.loggedIn), + loggedInAt: computed(() => session.value.loggedInAt), + ready, + fetch, + login, + logout, + } +} diff --git a/src/runtime/plugins/customer-account/session.ts b/src/runtime/plugins/customer-account/session.ts new file mode 100644 index 00000000..a101cf54 --- /dev/null +++ b/src/runtime/plugins/customer-account/session.ts @@ -0,0 +1,18 @@ +import { defineNuxtPlugin } from '#app' + +import { useCustomerAccountSession } from '../../composables/customer-account/session' + +export default defineNuxtPlugin({ + name: 'shopify:customer-account-session', + enforce: 'pre', + + async setup() { + if (import.meta.server && import.meta.prerender) return + + const { fetch, ready } = useCustomerAccountSession() + + if (!ready.value) { + await fetch() + } + }, +}) diff --git a/src/runtime/server/api/auth/customer-account/bridge.ts b/src/runtime/server/api/auth/customer-account/bridge.ts index 2626ff13..e306015f 100644 --- a/src/runtime/server/api/auth/customer-account/bridge.ts +++ b/src/runtime/server/api/auth/customer-account/bridge.ts @@ -1,8 +1,8 @@ -import { sendRedirect, getQuery, createError, defineEventHandler } from 'h3' -import { useRuntimeConfig } from 'nitropack/runtime' +import { createError, defineEventHandler, getQuery, sendRedirect } from 'h3' +import { useRuntimeConfig } from '#imports' -import { setUserSession } from '#imports' import { consumeBridgeNonce } from '../../../utils/customer-account/bridge' +import { setCustomerAccountSession } from '../../../utils/customer-account/session' export default defineEventHandler(async (event) => { const { _shopify } = useRuntimeConfig(event) @@ -11,6 +11,12 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, statusMessage: 'Not found' }) } + const customerAccount = _shopify?.clients?.customerAccount + + if (!customerAccount) { + throw createError({ statusCode: 500, statusMessage: '[shopify] Customer account client is not configured' }) + } + const nonce = getQuery(event).nonce if (typeof nonce !== 'string' || !nonce) { @@ -23,14 +29,11 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, statusMessage: '[shopify] Dev bridge invalid or expired nonce' }) } - await setUserSession(event, { + await setCustomerAccountSession(event, { user: payload.user, - secure: { - accessToken: payload.accessToken, - refreshToken: payload.refreshToken, - }, - loggedInAt: new Date(), + tokens: payload.tokens, + loggedInAt: Date.now(), }) - return sendRedirect(event, _shopify?.clients.customerAccount?.redirectURL || '/') + return sendRedirect(event, customerAccount.redirectURL) }) diff --git a/src/runtime/server/api/auth/customer-account/callback.ts b/src/runtime/server/api/auth/customer-account/callback.ts index 27a93536..4f4bd920 100644 --- a/src/runtime/server/api/auth/customer-account/callback.ts +++ b/src/runtime/server/api/auth/customer-account/callback.ts @@ -1,9 +1,31 @@ -import { createError, getRequestURL, sendRedirect } from 'h3' -import { useRuntimeConfig } from 'nitropack/runtime' -import { joinURL } from 'ufo' +import { createError, defineEventHandler, deleteCookie, getCookie, getQuery, getRequestURL, sendRedirect, setCookie } from 'h3' +import { useNitroApp } from 'nitropack/runtime' +import { useRuntimeConfig } from '#imports' +import { joinURL, withQuery } from 'ufo' -import { defineOAuthShopifyCustomerEventHandler, setUserSession } from '#imports' +import { createStoreDomain } from '../../../../utils/client' import { createBridgeNonce } from '../../../utils/customer-account/bridge' +import { setCustomerAccountSession } from '../../../utils/customer-account/session' +import { + buildAuthorizationParams, + buildAuthorizationURL, + exchangeAuthorizationCode, + fetchCustomerIdentity, + generateCodeChallenge, + generateRandomToken, + getOpenIdConfiguration, +} from '../../../utils/customer-account/oauth' + +const STATE_COOKIE = 'shopify-customer-account-state' +const VERIFIER_COOKIE = 'shopify-customer-account-verifier' + +const transientCookieOptions: Parameters[3] = { + httpOnly: true, + secure: !import.meta.dev, + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, +} function localConsumeUrl(bridgeURL: string, nonce: string) { const url = new URL(bridgeURL.includes('://') ? bridgeURL : joinURL('http://localhost:3000', bridgeURL)) @@ -17,55 +39,112 @@ function localConsumeUrl(bridgeURL: string, nonce: string) { return url.toString() } -export default defineOAuthShopifyCustomerEventHandler({ - async onSuccess(event, { user, tokens }) { - const { _shopify } = useRuntimeConfig(event) - const requestURL = getRequestURL(event) +export default defineEventHandler(async (event) => { + const { _shopify } = useRuntimeConfig(event) + + const customerAccount = _shopify?.clients?.customerAccount + + if (!_shopify || !customerAccount?.clientId || !customerAccount.apiUrl) { + throw createError({ statusCode: 500, statusMessage: '[shopify] Customer account client is not configured' }) + } + + const requestURL = getRequestURL(event) + const redirectUri = requestURL.origin + requestURL.pathname + + const configuration = await getOpenIdConfiguration(createStoreDomain(_shopify.name)) + + const nitroApp = useNitroApp() - const isDev = import.meta.dev - const tunnelURL = _shopify?.clients.customerAccount?.dev.tunnelURL - const bridgeURL = _shopify?.clients.customerAccount?.dev.bridgeURL - const isTunnelHost = tunnelURL?.length && bridgeURL?.length && requestURL.toString().includes(tunnelURL) + const query = getQuery(event) - if (!user || !tokens?.access_token || !tokens?.refresh_token) { - console.error('[shopify] OAuth success handler called but user or tokens are missing') + // First leg: no authorization code yet, so we start the OAuth flow. + if (!query.code) { + const state = generateRandomToken(16) - return sendRedirect(event, '/') + setCookie(event, STATE_COOKIE, state, transientCookieOptions) + + let codeChallenge: string | undefined + + if (!customerAccount.clientSecret) { + const codeVerifier = generateRandomToken(32) + + setCookie(event, VERIFIER_COOKIE, codeVerifier, transientCookieOptions) + + codeChallenge = await generateCodeChallenge(codeVerifier) } - if (isDev && isTunnelHost) { - const nonce = createBridgeNonce({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - user: { - firstName: user.firstName, - lastName: user.lastName, - email: user.emailAddress?.emailAddress, - }, - }) + const params = buildAuthorizationParams({ + clientId: customerAccount.clientId, + redirectUri, + scope: customerAccount.scope, + state, + codeChallenge, + }) + + await nitroApp.hooks.callHook('customer-account:auth:authorize', { params }) + + return sendRedirect(event, buildAuthorizationURL(configuration, params)) + } + + // Second leg: Shopify redirected back with an authorization code. + const expectedState = getCookie(event, STATE_COOKIE) + const codeVerifier = getCookie(event, VERIFIER_COOKIE) + + deleteCookie(event, STATE_COOKIE) + deleteCookie(event, VERIFIER_COOKIE) + + if (!expectedState || query.state !== expectedState) { + throw createError({ statusCode: 403, statusMessage: '[shopify] Invalid OAuth state' }) + } + + if (!customerAccount.clientSecret && !codeVerifier) { + throw createError({ statusCode: 400, statusMessage: '[shopify] Missing PKCE verifier' }) + } + + try { + const tokens = await exchangeAuthorizationCode(configuration, { + clientId: customerAccount.clientId, + clientSecret: customerAccount.clientSecret, + redirectUri, + code: String(query.code), + codeVerifier, + }) + + const user = await fetchCustomerIdentity(customerAccount.apiUrl, tokens.access_token) + + const tokenSet = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresAt: Date.now() + (tokens.expires_in ?? 7200) * 1000, + } + + await nitroApp.hooks.callHook('customer-account:auth:success', { user, tokens: tokenSet }) + + const tunnelURL = customerAccount.dev?.tunnelURL + const bridgeURL = customerAccount.dev?.bridgeURL + const isTunnelHost = import.meta.dev && tunnelURL?.length && bridgeURL?.length && requestURL.toString().includes(tunnelURL) + + // Dev bridge: hand the session off to localhost instead of persisting it on the tunnel host. + if (isTunnelHost) { + const nonce = createBridgeNonce({ user, tokens: tokenSet }) return sendRedirect(event, localConsumeUrl(bridgeURL, nonce)) } - await setUserSession(event, { - user: { - firstName: user.firstName, - lastName: user.lastName, - email: user.emailAddress.emailAddress, - }, - secure: { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - }, - loggedInAt: new Date(), + await setCustomerAccountSession(event, { + user, + tokens: tokenSet, + loggedInAt: Date.now(), }) - return sendRedirect(event, _shopify?.clients.customerAccount?.redirectURL || '/') - }, + return sendRedirect(event, customerAccount.redirectURL) + } + catch (error) { + console.error('[shopify] Customer account OAuth error:', error) - onError(event, error) { - console.error('[shopify] OAuth error:', error) + await nitroApp.hooks.callHook('customer-account:auth:error', { error }) - return sendRedirect(event, '/') - }, + return sendRedirect(event, withQuery(customerAccount.redirectURL, { customer_account_error: '1' })) + } }) diff --git a/src/runtime/server/api/auth/customer-account/logout.ts b/src/runtime/server/api/auth/customer-account/logout.ts index 6d860e57..e98a4eb4 100644 --- a/src/runtime/server/api/auth/customer-account/logout.ts +++ b/src/runtime/server/api/auth/customer-account/logout.ts @@ -1,16 +1,46 @@ -import { defineOAuthShopifyCustomerEventHandler, clearUserSession } from '#imports' -import { sendRedirect } from 'h3' +import { createError, defineEventHandler, getRequestHeader, getRequestURL, sendRedirect } from 'h3' +import { useNitroApp } from 'nitropack/runtime' +import { useRuntimeConfig } from '#imports' +import { joinURL } from 'ufo' -export default defineOAuthShopifyCustomerEventHandler({ - async onSuccess(event) { - await clearUserSession(event) +import { createStoreDomain } from '../../../../utils/client' +import { clearCustomerAccountSession, getCustomerAccountSession, getCustomerAccountTokens } from '../../../utils/customer-account/session' +import { buildLogoutURL, getOpenIdConfiguration } from '../../../utils/customer-account/oauth' - return sendRedirect(event, '/') - }, +export default defineEventHandler(async (event) => { + const { _shopify } = useRuntimeConfig(event) - onError(event, error) { - console.error('Shopify Customer OAuth error:', error) + const customerAccount = _shopify?.clients?.customerAccount - return sendRedirect(event, '/') - }, + if (!_shopify || !customerAccount) { + throw createError({ statusCode: 500, statusMessage: '[shopify] Customer account client is not configured' }) + } + + const secFetchSite = getRequestHeader(event, 'sec-fetch-site') + + if (secFetchSite && !['same-origin', 'same-site', 'none'].includes(secFetchSite)) { + throw createError({ statusCode: 403, statusMessage: '[shopify] Cross-site logout is not allowed' }) + } + + const { user } = await getCustomerAccountSession(event) + const tokens = await getCustomerAccountTokens(event) + const idToken = tokens?.idToken + + await useNitroApp().hooks.callHook('customer-account:auth:logout', { user, idToken }) + + await clearCustomerAccountSession(event) + + if (!import.meta.dev && idToken) { + const configuration = await getOpenIdConfiguration(createStoreDomain(_shopify.name)) + + const requestURL = getRequestURL(event) + const postLogoutRedirectUri = joinURL(requestURL.origin, customerAccount.logoutRedirectURL) + + return sendRedirect(event, buildLogoutURL(configuration, { + idToken, + postLogoutRedirectUri, + })) + } + + return sendRedirect(event, customerAccount.logoutRedirectURL) }) diff --git a/src/runtime/server/api/auth/customer-account/session.ts b/src/runtime/server/api/auth/customer-account/session.ts new file mode 100644 index 00000000..6e5da3fc --- /dev/null +++ b/src/runtime/server/api/auth/customer-account/session.ts @@ -0,0 +1,9 @@ +import type { CustomerAccountSession } from '../../../utils/customer-account/session' + +import { defineEventHandler } from 'h3' + +import { getCustomerAccountSession } from '../../../utils/customer-account/session' + +export default defineEventHandler(async (event): Promise => { + return await getCustomerAccountSession(event) +}) diff --git a/src/runtime/server/api/proxy/customer-account.ts b/src/runtime/server/api/proxy/customer-account.ts index cbcc833b..94c68b69 100644 --- a/src/runtime/server/api/proxy/customer-account.ts +++ b/src/runtime/server/api/proxy/customer-account.ts @@ -1,8 +1,9 @@ import { defineEventHandler, readValidatedBody } from 'h3' import { z } from 'zod' -import { useRuntimeConfig, getUserSession } from '#imports' +import { useRuntimeConfig } from '#imports' import { createCustomerAccountConfig } from '../../../utils/clients/customer-account' +import { getValidCustomerAccessToken } from '../../utils/customer-account/auth' export default defineEventHandler(async (event) => { const schema = z.object({ @@ -16,15 +17,13 @@ export default defineEventHandler(async (event) => { const { apiUrl } = createCustomerAccountConfig(_shopify) - const session = await getUserSession(event) - - const accessToken = session?.secure?.accessToken + const accessToken = await getValidCustomerAccessToken(event) return await $fetch(apiUrl, { method: event.method, headers: { 'Content-Type': 'application/json', - ...(accessToken ? { Authorization: accessToken } : {}), + 'Authorization': accessToken, }, body, }) diff --git a/src/runtime/server/utils/admin/auth.ts b/src/runtime/server/utils/admin/auth.ts index 54434dce..203e9ef9 100644 --- a/src/runtime/server/utils/admin/auth.ts +++ b/src/runtime/server/utils/admin/auth.ts @@ -1,6 +1,7 @@ import type { AdminOperations } from '../../../../clients/admin' import type { ShopifyApiClient, ShopifyConfig } from '../../../../types' +import type { AdminTokenSet } from './types' import { createError } from 'h3' @@ -8,23 +9,17 @@ import { createStoreDomain } from '../../../utils/client' type AdminConfig = NonNullable -type StoredToken = { - accessToken: string - expiresAt: number - refreshToken?: string -} - -let pendingAccessTokenRequest: Promise | undefined +let pendingAccessTokenRequest: Promise | undefined async function getTokenStorage(config: AdminConfig) { const storageBase = typeof config.tokenStorage === 'string' ? config.tokenStorage : 'admin-token' - return (await import('nitropack/runtime')).useStorage(storageBase) + return (await import('nitropack/runtime')).useStorage(storageBase) } -function isTokenExpired(token: StoredToken): boolean { +function isTokenExpired(token: AdminTokenSet): boolean { if (!token.expiresAt) return false // Refresh 5 minutes before expiry @@ -34,7 +29,7 @@ function isTokenExpired(token: StoredToken): boolean { async function fetchAccessToken( storeDomain: string, params: Record, -): Promise { +): Promise { const url = `${storeDomain}/admin/oauth/access_token` const response = await globalThis.fetch(url, { @@ -105,25 +100,45 @@ export async function getAdminAccessToken(shopName: string, config: AdminConfig, const storeDomain = createStoreDomain(shopName) - pendingAccessTokenRequest = fetchAccessToken(storeDomain, storedToken?.refreshToken + const isRefresh = !!storedToken?.refreshToken + + const params: Record = isRefresh ? { grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret, - refresh_token: storedToken.refreshToken, + refresh_token: storedToken!.refreshToken!, } : { grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, - }, - ).then(async (newToken) => { - if (store) { - await getTokenStorage(config).then(storage => storage.setItem('token', newToken)) + } + + pendingAccessTokenRequest = (async () => { + const nitroApp = await import('nitropack/runtime') + .then(({ useNitroApp }) => useNitroApp()) + .catch(() => undefined) + + try { + await nitroApp?.hooks.callHook('admin:auth:request', { params }) + + const newToken = await fetchAccessToken(storeDomain, params) + + if (store) { + await getTokenStorage(config).then(storage => storage.setItem('token', newToken)) + } + + await nitroApp?.hooks.callHook(isRefresh ? 'admin:auth:refresh' : 'admin:auth:success', { token: newToken }) + + return newToken } + catch (error) { + await nitroApp?.hooks.callHook('admin:auth:error', { error }) - return newToken - }).finally(() => { + throw error + } + })().finally(() => { pendingAccessTokenRequest = undefined }) diff --git a/src/runtime/server/utils/admin/types.ts b/src/runtime/server/utils/admin/types.ts new file mode 100644 index 00000000..fb02f1ab --- /dev/null +++ b/src/runtime/server/utils/admin/types.ts @@ -0,0 +1 @@ +export type { AdminTokenSet } from '../../../../types' diff --git a/src/runtime/server/utils/customer-account/auth.ts b/src/runtime/server/utils/customer-account/auth.ts index dea2c57a..11e406bc 100644 --- a/src/runtime/server/utils/customer-account/auth.ts +++ b/src/runtime/server/utils/customer-account/auth.ts @@ -2,19 +2,97 @@ import type { H3Event } from 'h3' import type { CustomerAccountOperations } from '../../../../clients/customer-account' import type { ShopifyApiClient } from '../../../../types' +import type { CustomerAccountSessionData, CustomerAccountTokenSet } from './session' -import { getUserSession } from '#imports' +import { createError, getSession } from 'h3' +import { useNitroApp } from 'nitropack/runtime' +import { useRuntimeConfig } from '#imports' -export const withCustomerAccountCredentials = async (client: ShopifyApiClient, event: H3Event): Promise> => { - const session = await getUserSession(event) +import { createStoreDomain } from '../../../utils/client' +import { getCustomerAccountTokenStorage, getSessionConfig } from './session' +import { getOpenIdConfiguration, refreshAccessToken } from './oauth' + +const EXPIRY_THRESHOLD_MS = 5 * 60 * 1000 + +const pendingRefreshRequests = new Map>() + +function isExpired(expiresAt?: number): boolean { + if (!expiresAt) return false + + return Date.now() >= expiresAt - EXPIRY_THRESHOLD_MS +} + +export async function getValidCustomerAccessToken(event: H3Event): Promise { + const { _shopify } = useRuntimeConfig(event) + + const customerAccount = _shopify?.clients?.customerAccount + + if (!_shopify || !customerAccount) { + throw createError({ statusCode: 500, message: '[shopify] Customer account client is not configured' }) + } + + const session = await getSession(event, getSessionConfig(_shopify)) + + if (!session.data.user || !session.id) { + throw createError({ statusCode: 401, message: '[shopify] No authenticated customer account session' }) + } + + const id = session.id + const storage = getCustomerAccountTokenStorage(_shopify) + + const tokens = await storage.getItem(id) - const accessToken = session?.secure?.accessToken + if (!tokens?.accessToken) { + throw createError({ statusCode: 401, message: '[shopify] Customer account session expired' }) + } + + if (!isExpired(tokens.expiresAt)) { + return tokens.accessToken + } - if (!accessToken) { - throw new Error('[shopify] Failed to obtain customer account access token: No access token found in user session') + if (!tokens.refreshToken) { + throw createError({ statusCode: 401, message: '[shopify] Customer account session expired' }) } - client.config.headers['Authorization'] = accessToken + if (!pendingRefreshRequests.has(id)) { + const refreshToken = tokens.refreshToken + + const request = getOpenIdConfiguration(createStoreDomain(_shopify.name)) + .then(configuration => refreshAccessToken(configuration, { + clientId: customerAccount.clientId, + clientSecret: customerAccount.clientSecret, + refreshToken, + })) + .then(async (fresh) => { + const next: CustomerAccountTokenSet = { + accessToken: fresh.access_token, + refreshToken: fresh.refresh_token ?? refreshToken, + idToken: fresh.id_token ?? tokens.idToken, + expiresAt: Date.now() + (fresh.expires_in ?? 7200) * 1000, + } + + await storage.setItem(id, next) + + await useNitroApp().hooks.callHook('customer-account:auth:refresh', { tokens: next }) + + return next + }) + .finally(() => pendingRefreshRequests.delete(id)) + + pendingRefreshRequests.set(id, request) + } + + const refreshed = await pendingRefreshRequests.get(id)!.catch((error) => { + console.error('[shopify] Failed to refresh the customer account session:', error) + + throw createError({ statusCode: 401, statusMessage: '[shopify] Customer account session expired' }) + }) + + return refreshed.accessToken +} + +export const withCustomerAccountCredentials = async (client: ShopifyApiClient, event: H3Event): Promise> => { + client.config.headers['Authorization'] = await getValidCustomerAccessToken(event) return client } diff --git a/src/runtime/server/utils/customer-account/bridge.ts b/src/runtime/server/utils/customer-account/bridge.ts index aa8d98ac..44bf729f 100644 --- a/src/runtime/server/utils/customer-account/bridge.ts +++ b/src/runtime/server/utils/customer-account/bridge.ts @@ -1,11 +1,8 @@ +import type { CustomerAccountTokenSet, CustomerAccountUser } from './session' + type BridgePayload = { - accessToken: string - refreshToken: string - user: { - firstName: string | null - lastName: string | null - email: string - } + user: CustomerAccountUser + tokens: CustomerAccountTokenSet expiresAt: number } diff --git a/src/runtime/server/utils/customer-account/oauth.ts b/src/runtime/server/utils/customer-account/oauth.ts new file mode 100644 index 00000000..61728bc0 --- /dev/null +++ b/src/runtime/server/utils/customer-account/oauth.ts @@ -0,0 +1,181 @@ +import { withQuery } from 'ufo' + +export type OpenIdConfiguration = { + issuer: string + authorization_endpoint: string + token_endpoint: string + end_session_endpoint: string +} + +export type CustomerAccountTokens = { + access_token: string + refresh_token?: string + id_token?: string + expires_in?: number + token_type?: string +} + +const openIdConfigurationCache = new Map>() + +export function getOpenIdConfiguration(storeDomain: string): Promise { + if (!openIdConfigurationCache.has(storeDomain)) { + const request = $fetch(`${storeDomain}/.well-known/openid-configuration`) + .catch((error) => { + openIdConfigurationCache.delete(storeDomain) + + throw new Error(`[shopify] Failed to fetch the customer account OpenID configuration: ${error}`) + }) + + openIdConfigurationCache.set(storeDomain, request) + } + + return openIdConfigurationCache.get(storeDomain)! +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = '' + + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function generateRandomToken(length = 32): string { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(length))) +} + +export async function generateCodeChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) + + return base64UrlEncode(new Uint8Array(digest)) +} + +function getBasicAuthHeader(clientId: string, clientSecret: string): string { + return `Basic ${btoa(`${clientId}:${clientSecret}`)}` +} + +export function buildAuthorizationParams(params: { + clientId: string + redirectUri: string + scope: string[] + state: string + codeChallenge?: string +}): Record { + return { + response_type: 'code', + client_id: params.clientId, + redirect_uri: params.redirectUri, + scope: [...new Set(params.scope)].join(' '), + state: params.state, + ...(params.codeChallenge + ? { + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + } + : {}), + } +} + +export function buildAuthorizationURL(configuration: OpenIdConfiguration, params: Record): string { + return withQuery(configuration.authorization_endpoint, params) +} + +async function requestTokens(tokenEndpoint: string, body: Record, basicAuth?: string): Promise { + const tokens = await $fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + ...(basicAuth ? { Authorization: basicAuth } : {}), + }, + body: new URLSearchParams(body).toString(), + }).catch((error) => { + throw new Error(`[shopify] Failed to obtain customer account tokens: ${error}`) + }) + + if (!tokens?.access_token) { + throw new Error('[shopify] Failed to obtain customer account tokens: no access token in response') + } + + return tokens +} + +export function exchangeAuthorizationCode(configuration: OpenIdConfiguration, params: { + clientId: string + clientSecret?: string + redirectUri: string + code: string + codeVerifier?: string +}): Promise { + const confidential = !!params.clientSecret + + return requestTokens(configuration.token_endpoint, { + grant_type: 'authorization_code', + client_id: params.clientId, + redirect_uri: params.redirectUri, + code: params.code, + ...(confidential ? {} : { code_verifier: params.codeVerifier! }), + }, confidential ? getBasicAuthHeader(params.clientId, params.clientSecret!) : undefined) +} + +export function refreshAccessToken(configuration: OpenIdConfiguration, params: { + clientId: string + clientSecret?: string + refreshToken: string +}): Promise { + const confidential = !!params.clientSecret + + return requestTokens(configuration.token_endpoint, { + grant_type: 'refresh_token', + client_id: params.clientId, + refresh_token: params.refreshToken, + }, confidential ? getBasicAuthHeader(params.clientId, params.clientSecret!) : undefined) +} + +export async function fetchCustomerIdentity(apiUrl: string, accessToken: string) { + const response = await $fetch<{ + data?: { + customer?: { + firstName: string | null + lastName: string | null + emailAddress: { emailAddress: string } | null + } + } + }>(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': accessToken, + }, + body: JSON.stringify({ + operationName: 'getCustomer', + query: 'query getCustomer { customer { firstName lastName emailAddress { emailAddress } } }', + }), + }).catch((error) => { + throw new Error(`[shopify] Failed to fetch the customer identity: ${error}`) + }) + + const customer = response?.data?.customer + + if (!customer?.emailAddress?.emailAddress) { + throw new Error('[shopify] Failed to fetch the customer identity: incomplete customer response') + } + + return { + firstName: customer.firstName, + lastName: customer.lastName, + email: customer.emailAddress.emailAddress, + } +} + +export function buildLogoutURL(configuration: OpenIdConfiguration, params: { + idToken: string + postLogoutRedirectUri: string +}): string { + return withQuery(configuration.end_session_endpoint, { + id_token_hint: params.idToken, + post_logout_redirect_uri: params.postLogoutRedirectUri, + }) +} diff --git a/src/runtime/server/utils/customer-account/session.ts b/src/runtime/server/utils/customer-account/session.ts new file mode 100644 index 00000000..dee631f3 --- /dev/null +++ b/src/runtime/server/utils/customer-account/session.ts @@ -0,0 +1,103 @@ +import type { H3Event, SessionConfig } from 'h3' +import type { Storage } from 'unstorage' + +import type { ShopifyConfig } from '../../../../types' +import type { CustomerAccountSession, CustomerAccountSessionData, CustomerAccountTokenSet, CustomerAccountUser } from './types' + +import { createError, getSession, useSession } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { useRuntimeConfig } from '#imports' + +export type { CustomerAccountSession, CustomerAccountSessionData, CustomerAccountTokenSet, CustomerAccountUser } from './types' + +export function getSessionConfig(config?: ShopifyConfig): SessionConfig { + const session = config?.clients?.customerAccount?.session + + if (!session?.password) { + throw createError({ + statusCode: 500, + message: '[shopify] Failed to resolve the customer account session: no session password configured. ' + + 'Set `shopify.clients.customerAccount.session.password` or the ' + + '`NUXT_SHOPIFY_CLIENTS_CUSTOMER_ACCOUNT_SESSION_PASSWORD` environment variable.', + }) + } + + return { + name: session.name, + password: session.password, + maxAge: session.maxAge, + cookie: { + sameSite: 'lax', + secure: !import.meta.dev, + ...session.cookie, + httpOnly: true, + }, + } +} + +export function getCustomerAccountTokenStorage(config?: ShopifyConfig): Storage { + const tokenStorage = config?.clients?.customerAccount?.tokenStorage + + const base = typeof tokenStorage === 'string' ? tokenStorage : 'customer-account-token' + + return useStorage(base) +} + +export async function getCustomerAccountSession(event: H3Event): Promise { + const { _shopify } = useRuntimeConfig(event) + + const session = await getSession(event, getSessionConfig(_shopify)) + + return { + loggedIn: !!session.data.user, + user: session.data.user ?? null, + loggedInAt: session.data.loggedInAt ?? null, + } +} + +export async function requireCustomerAccountSession(event: H3Event): Promise { + const session = await getCustomerAccountSession(event) + + if (!session.user) { + throw createError({ + statusCode: 401, + message: '[shopify] No authenticated customer account session', + }) + } + + return session +} + +export async function setCustomerAccountSession(event: H3Event, data: { user: CustomerAccountUser, tokens: CustomerAccountTokenSet, loggedInAt: number }): Promise { + const { _shopify } = useRuntimeConfig(event) + + const session = await useSession(event, getSessionConfig(_shopify)) + + await session.update({ user: data.user, loggedInAt: data.loggedInAt }) + + await getCustomerAccountTokenStorage(_shopify).setItem(session.id!, data.tokens) +} + +export async function getCustomerAccountTokens(event: H3Event): Promise { + const { _shopify } = useRuntimeConfig(event) + + const session = await getSession(event, getSessionConfig(_shopify)) + + if (!session.data.user || !session.id) return null + + return await getCustomerAccountTokenStorage(_shopify).getItem(session.id) +} + +export async function clearCustomerAccountSession(event: H3Event): Promise { + const { _shopify } = useRuntimeConfig(event) + + const session = await useSession(event, getSessionConfig(_shopify)) + + const id = session.id + + await session.clear() + + if (id) { + await getCustomerAccountTokenStorage(_shopify).removeItem(id) + } +} diff --git a/src/runtime/server/utils/customer-account/types.ts b/src/runtime/server/utils/customer-account/types.ts new file mode 100644 index 00000000..3d353960 --- /dev/null +++ b/src/runtime/server/utils/customer-account/types.ts @@ -0,0 +1,14 @@ +import type { CustomerAccountUser } from '../../../../types' + +export type { CustomerAccountTokenSet, CustomerAccountUser } from '../../../../types' + +export type CustomerAccountSessionData = { + user?: CustomerAccountUser + loggedInAt?: number +} + +export type CustomerAccountSession = { + loggedIn: boolean + user: CustomerAccountUser | null + loggedInAt: number | null +} diff --git a/src/schemas/config.ts b/src/schemas/config.ts index 5102cbdf..0ff16f76 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -42,12 +42,29 @@ export const storefrontClientSchema = clientSchema.extend({ cache: clientCacheSchema.or(z.boolean()).optional(), }) +export const customerAccountSessionSchema = z.object({ + name: z.string().optional(), + password: z.string().optional(), + maxAge: z.number().optional(), + cookie: z.object({ + domain: z.string().optional(), + path: z.string().optional(), + sameSite: z.enum(['lax', 'strict', 'none']).optional(), + secure: z.boolean().optional(), + }).optional(), +}) + export const customerAccountClientSchema = clientSchema.extend({ clientId: z.string(), + clientSecret: z.string().optional(), scope: z.array(z.string()).optional(), redirectURL: z.string().optional(), loginURL: z.string().optional(), logoutURL: z.string().optional(), + logoutRedirectURL: z.string().optional(), + sessionURL: z.string().optional(), + session: customerAccountSessionSchema.optional(), + tokenStorage: z.any().transform(v => v as StorageMounts[string]).or(z.string()).or(z.boolean()).optional(), dev: z.object({ tunnelURL: z.string().optional(), bridgeURL: z.string().optional(), @@ -128,6 +145,10 @@ export const publicModuleOptionsSchema = moduleOptionsSchema.omit({ documents: true, codegen: true, autoImport: true, + clientSecret: true, + session: true, + tokenStorage: true, + logoutRedirectURL: true, }).optional().optional(), }), }) diff --git a/src/schemas/runtime.ts b/src/schemas/runtime.ts index 02abf023..5d52af8f 100644 --- a/src/schemas/runtime.ts +++ b/src/schemas/runtime.ts @@ -17,6 +17,7 @@ import { moduleOptionsSchema, publicModuleOptionsSchema, clientCacheSchema, + customerAccountSessionSchema, } from './config' const ignores = [ @@ -111,17 +112,35 @@ const storefrontClientSchemaWithDefaults = clientSchemaWithDefaults.omit({ autoImport: storefrontClientSchema.shape.autoImport.default(true), }) +const defaultCustomerAccountSessionOptions = { name: 'shopify-customer-account', maxAge: 60 * 60 * 24 * 7 } +const defaultCustomerAccountScope = ['openid', 'email', 'customer-account-api:full'] + +const customerAccountSessionSchemaWithDefaults = customerAccountSessionSchema.omit({ + name: true, + maxAge: true, +}).extend({ + name: customerAccountSessionSchema.shape.name.default(defaultCustomerAccountSessionOptions.name), + maxAge: customerAccountSessionSchema.shape.maxAge.default(defaultCustomerAccountSessionOptions.maxAge), +}) + const customerAccountClientSchemaWithDefaults = clientSchemaWithDefaults.omit({ documents: true, }).extend({ apiUrl: z.string().optional(), clientId: customerAccountClientSchema.shape.clientId, - scope: customerAccountClientSchema.shape.scope, - redirectURL: customerAccountClientSchema.shape.redirectURL, + clientSecret: customerAccountClientSchema.shape.clientSecret, + scope: customerAccountClientSchema.shape.scope.default(defaultCustomerAccountScope).transform(v => v?.length ? v : defaultCustomerAccountScope), + redirectURL: customerAccountClientSchema.shape.redirectURL.default('/'), + logoutRedirectURL: customerAccountClientSchema.shape.logoutRedirectURL, loginURL: customerAccountClientSchema.shape.loginURL.default('_auth/customer-account/callback'), logoutURL: customerAccountClientSchema.shape.logoutURL.default('_auth/customer-account/logout'), + sessionURL: customerAccountClientSchema.shape.sessionURL.default('_auth/customer-account/session'), + + session: customerAccountSessionSchemaWithDefaults.optional().transform(v => ({ ...defaultCustomerAccountSessionOptions, ...(v ?? {}) })), + + tokenStorage: customerAccountClientSchema.shape.tokenStorage.default(defaultTokenStorageOptions).transform(v => v === true ? defaultTokenStorageOptions : v), documents: customerAccountClientSchema.shape.documents.transform(v => v ? v : defaultCustomerAccountDocuments), proxy: customerAccountClientSchema.shape.proxy.default({ path: '_proxy/customer-account' }).transform(v => typeof v === 'undefined' || v === true ? { path: '_proxy/customer-account' } : v), @@ -154,7 +173,10 @@ export const moduleOptionsSchemaWithDefaults = moduleOptionsSchema.omit({ error: 'Either a public or private access token must be provided for the storefront client', }).optional(), - [ShopifyClientType.CustomerAccount]: customerAccountClientSchemaWithDefaults.refine( + [ShopifyClientType.CustomerAccount]: customerAccountClientSchemaWithDefaults.transform(client => ({ + ...client, + logoutRedirectURL: client.logoutRedirectURL ?? client.redirectURL, + })).refine( client => client.clientId, { message: 'Client ID is required for the customer account client', }, @@ -214,6 +236,10 @@ export const publicModuleOptionsSchemaWithDefaults = publicModuleOptionsSchema.o codegen: true, autoImport: true, proxy: true, + clientSecret: true, + session: true, + tokenStorage: true, + logoutRedirectURL: true, }).and(z.object({ proxy: z.object({ path: z.string().optional().default('_proxy/customer-account'), diff --git a/src/setup/auth.ts b/src/setup/auth.ts index 0d95e8b7..1f2d4ca5 100644 --- a/src/setup/auth.ts +++ b/src/setup/auth.ts @@ -11,4 +11,13 @@ export default async function setupAuth(nuxt: Nuxt, config: ShopifyConfig) { nuxt.options.nitro.storage ??= {} nuxt.options.nitro.storage['admin-token'] = adminTokenStorageMount } + + const customerAccountTokenStorageMount = typeof config.clients.customerAccount?.tokenStorage === 'object' + ? config.clients.customerAccount.tokenStorage + : undefined + + if (customerAccountTokenStorageMount) { + nuxt.options.nitro.storage ??= {} + nuxt.options.nitro.storage['customer-account-token'] = customerAccountTokenStorageMount + } } diff --git a/src/setup/clients.ts b/src/setup/clients.ts index e4f0261e..a9cb22c4 100644 --- a/src/setup/clients.ts +++ b/src/setup/clients.ts @@ -3,9 +3,8 @@ import type { Nuxt } from '@nuxt/schema' import type { ShopifyConfig } from '../types' -import { addServerHandler, addServerImports, addTemplate, addTypeTemplate, hasNuxtModule } from '@nuxt/kit' -import defu from 'defu' -import { withLeadingSlash, withoutHost, withoutProtocol } from 'ufo' +import { addImports, addPlugin, addServerHandler, addServerImports } from '@nuxt/kit' +import { joinURL, withLeadingSlash, withoutHost } from 'ufo' import { useLogger } from '../utils/log' import { @@ -17,7 +16,7 @@ import { } from '../utils/clients' import { ShopifyClientType } from '../schemas' import { createStoreDomain } from '../runtime/utils/client' -import { nuxtAuthUtilsDevTemplate, nuxtAuthUtilsTemplate } from '../templates/auth-utils' +import { SESSION_PASSWORD_ENV, generateSessionPassword, persistSessionPassword } from '../utils/session' export default async function setupClients(nuxt: Nuxt, config: ShopifyConfig, resolver: Resolver) { const logger = useLogger(config) @@ -34,20 +33,7 @@ export default async function setupClients(nuxt: Nuxt, config: ShopifyConfig, re } if (clientType === ShopifyClientType.CustomerAccount && config.clients[clientType]) { - if (!hasNuxtModule('nuxt-auth-utils', nuxt)) { - logger.warn('nuxt-auth-utils is required to use the Customer Account API client.') - continue - } - - const nuxtAuthUtilsConfig = { - shopDomain: withoutProtocol(createStoreDomain(config.name)), - clientId: config.clients[clientType]?.clientId, - scope: config.clients[clientType]?.scope, - } - - nuxt.options.runtimeConfig.oauth = defu({ - shopifyCustomer: nuxtAuthUtilsConfig, - }, nuxt.options.runtimeConfig.oauth || {}) + const customerAccount = config.clients[clientType] if ( nuxt.options.runtimeConfig._shopify?.clients.customerAccount @@ -61,50 +47,78 @@ export default async function setupClients(nuxt: Nuxt, config: ShopifyConfig, re nuxt.options.runtimeConfig.public._shopify.clients.customerAccount.apiUrl = apiUrl } + const session = nuxt.options.runtimeConfig._shopify?.clients.customerAccount?.session + + if (session && !session.password) { + const envPassword = process.env[SESSION_PASSWORD_ENV] + + if (envPassword) { + session.password = envPassword + } + else if (nuxt.options.dev) { + const password = generateSessionPassword() + + session.password = password + + await persistSessionPassword(nuxt.options.rootDir, password) + + logger.info('Generated a customer account session password and stored it in `.env`.') + } + else { + logger.warn(`No customer account session password set. Set the \`${SESSION_PASSWORD_ENV}\` environment variable.`) + } + } + addServerHandler({ method: 'get', - route: withLeadingSlash(config.clients[clientType].loginURL), + route: withLeadingSlash(customerAccount.loginURL), handler: resolver.resolve('./runtime/server/api/auth/customer-account/callback'), }) addServerHandler({ method: 'get', - route: withLeadingSlash(config.clients[clientType].logoutURL), + route: withLeadingSlash(customerAccount.logoutURL), handler: resolver.resolve('./runtime/server/api/auth/customer-account/logout'), }) - if (nuxt.options.dev && config.clients[clientType].dev?.tunnelURL && config.clients[clientType].dev?.bridgeURL) { + addServerHandler({ + method: 'get', + route: withLeadingSlash(customerAccount.sessionURL), + handler: resolver.resolve('./runtime/server/api/auth/customer-account/session'), + }) + + if (nuxt.options.dev && customerAccount.dev?.tunnelURL && customerAccount.dev?.bridgeURL) { + const bridgePath = withLeadingSlash(withoutHost(customerAccount.dev.bridgeURL)) + addServerHandler({ method: 'get', - route: withLeadingSlash(withoutHost(config.clients[clientType].dev.bridgeURL)), + route: bridgePath, handler: resolver.resolve('./runtime/server/api/auth/customer-account/bridge'), }) + + // Resolve the bridge URL to an absolute dev-server URL so the handoff targets the real port. + const bridgeURL = joinURL(nuxt.options.devServer.url, bridgePath) + + if (nuxt.options.runtimeConfig._shopify?.clients.customerAccount?.dev) { + nuxt.options.runtimeConfig._shopify.clients.customerAccount.dev.bridgeURL = bridgeURL + } } - addTypeTemplate({ - filename: 'shopify/auth-utils.d.ts', - getContents: () => nuxtAuthUtilsTemplate, - }, { - nuxt: true, - nitro: true, - }) + addImports([{ + from: resolver.resolve('./runtime/composables/customer-account/session'), + name: 'useCustomerAccountSession', + }]) + + addServerImports([ + 'getCustomerAccountSession', + 'requireCustomerAccountSession', + 'clearCustomerAccountSession', + ].map(name => ({ + from: resolver.resolve('./runtime/server/utils/customer-account/session'), + name, + }))) + + addPlugin(resolver.resolve('./runtime/plugins/customer-account/session')) } } - - if (!hasNuxtModule('nuxt-auth-utils', nuxt)) { - addTypeTemplate({ - filename: 'shopify/auth-utils.dev.d.ts', - getContents: () => nuxtAuthUtilsDevTemplate, - }, { - nitro: true, - }) - - const { dst } = addTemplate({ - filename: 'shopify/auth-utils.mjs', - getContents: () => 'export const getUserSession = async () => {}', - write: true, - }) - - addServerImports([{ from: resolver.resolve(dst), name: 'getUserSession' }]) - } } diff --git a/src/setup/dev.ts b/src/setup/dev.ts index 1dcacaf6..68c7b46e 100644 --- a/src/setup/dev.ts +++ b/src/setup/dev.ts @@ -2,11 +2,9 @@ import type { Nuxt } from '@nuxt/schema' import type { ConsolaInstance } from 'consola' import { stat } from 'node:fs/promises' -import { addTypeTemplate, createResolver } from '@nuxt/kit' +import { createResolver } from '@nuxt/kit' import defu from 'defu' -import { nuxtAuthUtilsDevTemplate, nuxtAuthUtilsTemplate } from '../templates/auth-utils' - export default async function setupDevMode(nuxt: Nuxt, logger: ConsolaInstance) { const resolver = createResolver(import.meta.url) @@ -53,18 +51,5 @@ export default async function setupDevMode(nuxt: Nuxt, logger: ConsolaInstance) }, }, }) - - addTypeTemplate({ - filename: 'shopify/auth-utils.d.ts', - getContents: () => nuxtAuthUtilsTemplate, - }, { - nuxt: true, - nitro: true, - }) - - addTypeTemplate({ - filename: 'shopify/auth-utils.dev.d.ts', - getContents: () => nuxtAuthUtilsDevTemplate, - }) } } diff --git a/src/templates/auth-utils.ts b/src/templates/auth-utils.ts deleted file mode 100644 index 5f8db53f..00000000 --- a/src/templates/auth-utils.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Augmentations for the customer account API with nuxt-auth-utils - */ -export const nuxtAuthUtilsTemplate = ` -declare module '#auth-utils' { - interface User { - firstName: string | null - lastName: string | null - email: string - } - - interface SecureSessionData { - accessToken: string - refreshToken: string - } -} - -export {} -`.trimStart() - -/** - * Augmentations for auto-imported functions from nuxt-auth-utils in development mode - */ -export const nuxtAuthUtilsDevTemplate = ` -import type { defineEventHandler, H3Event } from 'h3' -import type { User, SecureSessionData } from '#auth-utils' - -declare module '#imports' { - const defineOAuthShopifyCustomerEventHandler: (options: { - onSuccess: (event: H3Event, params: { user: { firstName: string, lastName: string, emailAddress: { emailAddress: string } }, tokens: { access_token: string, refresh_token?: string } }) => Promise - onError: (event: H3Event, error: Error) => Promise - }) => ReturnType - - const setUserSession: (event: H3Event, sessionData: { user: User, secure: SecureSessionData, loggedInAt: Date }) => Promise - const getUserSession: (event: H3Event) => Promise<{ user: User | null, secure: SecureSessionData | null, loggedInAt: Date | null } | null> - const clearUserSession: (event: H3Event) => Promise -} -`.trimStart() diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 78216426..e63af473 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -23,6 +23,25 @@ import type { ShopifyApiClientRequestOptions, } from './client' +type CustomerAccountUser = { + firstName: string | null + lastName: string | null + email: string +} + +type CustomerAccountTokenSet = { + accessToken: string + refreshToken?: string + idToken?: string + expiresAt: number +} + +type AdminTokenSet = { + accessToken: string + refreshToken?: string + expiresAt: number +} + type ShopifyConfigHookParams = { nuxt: Nuxt config: ShopifyConfig @@ -56,6 +75,40 @@ type ShopifyTemplateHookParams = { config: Record } +type ShopifyCustomerAccountAuthorizeHookParams = { + params: Record +} + +type ShopifyCustomerAccountAuthSuccessHookParams = { + user: CustomerAccountUser + tokens: CustomerAccountTokenSet +} + +type ShopifyCustomerAccountAuthRefreshHookParams = { + tokens: CustomerAccountTokenSet +} + +type ShopifyCustomerAccountAuthLogoutHookParams = { + user: CustomerAccountUser | null + idToken?: string +} + +type ShopifyCustomerAccountAuthErrorHookParams = { + error: unknown +} + +type ShopifyAdminAuthRequestHookParams = { + params: Record +} + +type ShopifyAdminAuthTokenHookParams = { + token: AdminTokenSet +} + +type ShopifyAdminAuthErrorHookParams = { + error: unknown +} + declare module '@nuxt/schema' { interface RuntimeConfig { shopify?: ModuleOptions @@ -239,6 +292,53 @@ declare module 'nitropack/types' { */ 'customer-account:client:errors': ({ errors }: ShopifyErrorHookParams) => HookResult + /** + * Called before redirecting the user to Shopify to start the customer account OAuth flow. + * The `params` object is the authorization request query and may be mutated (e.g. to add a `locale`). + */ + 'customer-account:auth:authorize': ({ params }: ShopifyCustomerAccountAuthorizeHookParams) => HookResult + + /** + * Called after a successful customer account login, before the session is persisted. + */ + 'customer-account:auth:success': ({ user, tokens }: ShopifyCustomerAccountAuthSuccessHookParams) => HookResult + + /** + * Called after the customer account access token has been refreshed. + */ + 'customer-account:auth:refresh': ({ tokens }: ShopifyCustomerAccountAuthRefreshHookParams) => HookResult + + /** + * Called before the customer account session is cleared on logout. + */ + 'customer-account:auth:logout': ({ user, idToken }: ShopifyCustomerAccountAuthLogoutHookParams) => HookResult + + /** + * Called when the customer account OAuth flow fails. + */ + 'customer-account:auth:error': ({ error }: ShopifyCustomerAccountAuthErrorHookParams) => HookResult + + /** + * Called before the admin API access token request is sent. + * The `params` object is the token request body and may be mutated (e.g. to add a `scope`). + */ + 'admin:auth:request': ({ params }: ShopifyAdminAuthRequestHookParams) => HookResult + + /** + * Called after a new admin API access token is obtained via the client credentials grant. + */ + 'admin:auth:success': ({ token }: ShopifyAdminAuthTokenHookParams) => HookResult + + /** + * Called after the admin API access token has been refreshed via the refresh token grant. + */ + 'admin:auth:refresh': ({ token }: ShopifyAdminAuthTokenHookParams) => HookResult + + /** + * Called when the admin API access token request fails. + */ + 'admin:auth:error': ({ error }: ShopifyAdminAuthErrorHookParams) => HookResult + /** * Called before the admin client is created within nitro */ @@ -272,6 +372,9 @@ export type { PublicModuleOptions, ShopifyConfig, PublicShopifyConfig, + CustomerAccountUser, + CustomerAccountTokenSet, + AdminTokenSet, } export type { diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 00000000..c32a8e53 --- /dev/null +++ b/src/utils/session.ts @@ -0,0 +1,27 @@ +import { randomBytes } from 'node:crypto' +import { existsSync } from 'node:fs' +import { appendFile, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export const SESSION_PASSWORD_ENV = 'NUXT_SHOPIFY_CLIENTS_CUSTOMER_ACCOUNT_SESSION_PASSWORD' + +export function generateSessionPassword(): string { + return randomBytes(48).toString('base64url') +} + +export async function persistSessionPassword(rootDir: string, password: string): Promise { + const envPath = join(rootDir, '.env') + const line = `${SESSION_PASSWORD_ENV}="${password}"` + + if (!existsSync(envPath)) { + await writeFile(envPath, `${line}\n`, 'utf8') + + return + } + + const content = await readFile(envPath, 'utf8') + + if (content.includes(SESSION_PASSWORD_ENV)) return + + await appendFile(envPath, `${content.endsWith('\n') || content.length === 0 ? '' : '\n'}${line}\n`, 'utf8') +} diff --git a/template/package.json b/template/package.json index 428126ba..dc0a709f 100644 --- a/template/package.json +++ b/template/package.json @@ -19,7 +19,6 @@ "@nuxtjs/shopify": "latest", "@tailwindcss/typography": "^0.5.20", "nuxt": "^4.4.8", - "nuxt-auth-utils": "^0.5.29", "zod": "^4.4.3" }, "devDependencies": {