diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 414b3e0c..fdc1977c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,7 @@ * @netlify/team-runtime /packages/dev/**/* @netlify/ecosystem-pod-frameworks /packages/dev-utils/**/* @netlify/ecosystem-pod-frameworks +/packages/identity/**/* @netlify/pod-experience /packages/nuxt-module/**/* @netlify/ecosystem-pod-frameworks /packages/vite-plugin/**/* @netlify/ecosystem-pod-frameworks /‎packages/vite-plugin-tanstack-start/**/* @netlify/ecosystem-pod-frameworks diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 80db909a..73527f81 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -181,6 +181,17 @@ jobs: fi env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - if: + ${{ steps.release.outputs['packages/identity/prod--release_created'] || github.event_name == + 'workflow_dispatch' }} + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + npm publish packages/identity/prod/ --provenance --access=public || true + else + npm publish packages/identity/prod/ --provenance --access=public + fi + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - if: ${{ steps.release.outputs['packages/images--release_created'] || github.event_name == 'workflow_dispatch' }} run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cf5ef046..727daeb9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -13,6 +13,7 @@ "packages/functions/prod": "5.2.0", "packages/functions/dev": "1.2.6", "packages/headers": "2.1.8", + "packages/identity/prod": "1.0.0", "packages/images": "1.3.7", "packages/nuxt-module": "0.3.1", "packages/otel": "5.1.5", diff --git a/eslint_temporary_suppressions.js b/eslint_temporary_suppressions.js index 69d7c5d1..ad24f912 100644 --- a/eslint_temporary_suppressions.js +++ b/eslint_temporary_suppressions.js @@ -524,6 +524,127 @@ export default [ '@typescript-eslint/no-unused-vars': 'off', }, }, + { + files: ['packages/identity/prod/src/account.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + }, + { + files: ['packages/identity/prod/src/admin.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + }, + }, + { + files: ['packages/identity/prod/src/auth.ts'], + rules: { + '@typescript-eslint/no-redundant-type-constituents': 'off', + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, + { + files: ['packages/identity/prod/src/config.ts'], + rules: { + '@typescript-eslint/no-unnecessary-condition': 'off', + }, + }, + { + files: ['packages/identity/prod/src/cookies.ts'], + rules: { + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, + { + files: ['packages/identity/prod/src/environment.ts'], + rules: { + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + }, + { + files: ['packages/identity/prod/src/events.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + }, + }, + { + files: ['packages/identity/prod/src/nextjs.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + }, + { + files: ['packages/identity/prod/src/refresh.ts'], + rules: { + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, + { + files: ['packages/identity/prod/src/user.ts'], + rules: { + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + }, + { + files: ['packages/identity/prod/test/account.browser.test.ts'], + rules: { + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, + { + files: ['packages/identity/prod/test/admin.server.test.ts'], + rules: { + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + }, + { + files: ['packages/identity/prod/test/auth.browser.test.ts'], + rules: { + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, + { + files: ['packages/identity/prod/test/auth.server.test.ts'], + rules: { + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, + { + files: ['packages/identity/prod/test/refresh.browser.test.ts'], + rules: { + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, + { + files: ['packages/identity/prod/test/user.test.ts'], + rules: { + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + }, { files: ['packages/images/src/main.test.ts'], rules: { diff --git a/package-lock.json b/package-lock.json index e0105fcf..3bbeee9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "packages/dev", "packages/nuxt-module", "packages/aws-lambda-compat", + "packages/identity/prod", "packages/vite-plugin", "packages/vite-plugin-tanstack-start" ], @@ -47,6 +48,13 @@ "typescript-eslint": "^8.33.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -68,6 +76,53 @@ "dev": true, "license": "MIT" }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -506,6 +561,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -594,6 +662,156 @@ "node": ">=0.1.90" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -1349,6 +1567,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -3029,6 +3265,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@netlify/identity": { + "resolved": "packages/identity/prod", + "link": true + }, "node_modules/@netlify/images": { "resolved": "packages/images", "link": true @@ -8939,6 +9179,16 @@ "ajv": "4.11.8 - 8" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -9831,13 +10081,13 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -9986,6 +10236,22 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -10002,6 +10268,65 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/db0": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz", @@ -10107,6 +10432,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -12222,6 +12554,12 @@ "node": ">=0.6.0" } }, + "node_modules/gotrue-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gotrue-js/-/gotrue-js-1.0.1.tgz", + "integrity": "sha512-ZynwwM4bEo2y5yNWO3/Ynbx1AvQz9ONJSM9tB1jpooAvDPevGdvgeFP3NwM59FAlkd0szSkQRAhjINMkJSDfAQ==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -12345,6 +12683,21 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -12372,6 +12725,20 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-shutdown": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/http-shutdown/-/http-shutdown-1.2.2.tgz", @@ -12427,6 +12794,21 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -12836,7 +13218,14 @@ "node": ">=8" } }, - "node_modules/is-reference": { + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", @@ -13058,6 +13447,142 @@ "node": ">=12.0.0" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/jsdom/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -13934,9 +14459,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, "node_modules/memorystream": { @@ -15123,6 +15648,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", @@ -15635,6 +16169,36 @@ "node": ">=14.13.0" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -17658,6 +18222,15 @@ "node": ">= 12" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -17723,12 +18296,34 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scslre": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", @@ -18471,6 +19066,13 @@ "node": ">=16" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/system-architecture": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", @@ -19673,6 +20275,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -21121,6 +21733,29 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -21142,6 +21777,34 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -21424,6 +22087,13 @@ "node": ">=12" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xss": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", @@ -22859,6 +23529,347 @@ "dev": true, "license": "MIT" }, + "packages/identity/prod": { + "name": "@netlify/identity", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "gotrue-js": "^1.0.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "jsdom": "^28.1.0", + "tsup": "^8.0.0", + "vitest": "^3.0.0" + } + }, + "packages/identity/prod/node_modules/@asamuzakjp/css-color": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", + "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "packages/identity/prod/node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "packages/identity/prod/node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "packages/identity/prod/node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "packages/identity/prod/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "packages/identity/prod/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "packages/identity/prod/node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/identity/prod/node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "packages/identity/prod/node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "packages/identity/prod/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "packages/identity/prod/node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "packages/identity/prod/node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "packages/identity/prod/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "packages/identity/prod/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "packages/identity/prod/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "packages/identity/prod/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "packages/identity/prod/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "packages/identity/prod/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "packages/identity/prod/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "packages/identity/prod/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "packages/images": { "name": "@netlify/images", "version": "1.3.7", diff --git a/package.json b/package.json index c06524a4..d9c160fe 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "packages/dev", "packages/nuxt-module", "packages/aws-lambda-compat", + "packages/identity/prod", "packages/vite-plugin", "packages/vite-plugin-tanstack-start" ], diff --git a/packages/identity/.gitignore b/packages/identity/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/packages/identity/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/identity/prod/.gitignore b/packages/identity/prod/.gitignore new file mode 100644 index 00000000..6a5f7513 --- /dev/null +++ b/packages/identity/prod/.gitignore @@ -0,0 +1,12 @@ +*~ +*.swp +npm-debug.log +node_modules +/core +.eslintcache +.npmrc +.yarn-error.log +/coverage +/build +.vscode +/dist diff --git a/packages/identity/prod/README.md b/packages/identity/prod/README.md new file mode 100644 index 00000000..f0bb980d --- /dev/null +++ b/packages/identity/prod/README.md @@ -0,0 +1,1261 @@ +# @netlify/identity + +A lightweight, no-config headless authentication library for projects using Netlify Identity. Works in both browser and +server contexts. This is NOT the Netlify Identity Widget. This library exports standalone async functions (e.g., import +{ login, getUser } from '@netlify/identity'). There is no class to instantiate and no .init() call. Just import the +functions you need and call them. + +**Prerequisites:** + +- [Netlify Identity](https://docs.netlify.com/security/secure-access-to-sites/identity/) must be enabled on your Netlify + project. This happens automatically when running within a + [Netlify Agent Runner](https://docs.netlify.com/agent-runner/overview/) +- **Server-side** functions (`getUser`, `login`, `admin.*`, etc.) require + [Netlify Functions](https://docs.netlify.com/build/functions/get-started/) (modern/v2, with `export default`) or + [Edge Functions](https://docs.netlify.com/edge-functions/overview/). + [Lambda-compatible functions](https://docs.netlify.com/build/functions/lambda-compatibility/) (v1, with + `export { handler }`) are **not supported** +- For local development, use [`netlify dev`](https://docs.netlify.com/cli/local-development/) so the Identity endpoint + is available + +## How this library relates to other Netlify auth packages + +`@netlify/identity` is the recommended library for all new projects. It works in both browser and server contexts, +handles cookie management, and normalizes the user object. + +You may encounter two older packages in existing code or documentation: + +| Package | Status | What it was | +| ------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------- | +| [`netlify-identity-widget`](https://github.com/netlify/netlify-identity-widget) | Not recommended for new projects | Pre-built login/signup modal with built-in UI | +| [`gotrue-js`](https://github.com/netlify/gotrue-js) | Not recommended for new projects | Low-level GoTrue HTTP client (browser only) | + +If you need a pre-built login UI, the widget still works. For everything else (custom UI, server-side auth, admin +operations, framework integration), use `@netlify/identity`. + +## Table of contents + +- [Installation](#installation) +- [Quick start](#quick-start) +- [API](#api) + - [Functions](#functions) -- `getUser`, `login`, `signup`, `logout`, `oauthLogin`, `handleAuthCallback`, + `onAuthChange`, `hydrateSession`, `refreshSession`, `verifyRequestOrigin`, and more + - [Admin Operations](#admin-operations) -- `admin.listUsers`, `admin.getUser`, `admin.createUser`, `admin.updateUser`, + `admin.deleteUser` + - [Types](#types) -- `User`, `AuthEvent`, `CallbackResult`, `Settings`, `Admin`, `ListUsersOptions`, + `CreateUserParams`, `VerifyRequestOriginOptions`, etc. + - [Errors](#errors) -- `AuthError`, `MissingIdentityError` +- [Security: CSRF protection](#security-csrf-protection) +- [Framework integration](#framework-integration) -- Next.js, Remix, TanStack Start, Astro, SvelteKit +- [Guides](#guides) + - [React `useAuth` hook](#react-useauth-hook) + - [Listening for auth changes](#listening-for-auth-changes) + - [OAuth login](#oauth-login) + - [Password recovery](#password-recovery) + - [Invite acceptance](#invite-acceptance) + - [Session lifetime](#session-lifetime) + - [Caching and authenticated content](#caching-and-authenticated-content) + +## Installation + +```bash +npm install @netlify/identity +``` + +## Quick start + +### Log in (browser) + +```ts +import { login, getUser } from '@netlify/identity' + +// Log in +const user = await login('jane@example.com', 'password123') +console.log(`Hello, ${user.name}`) + +// Later, check auth state +const currentUser = await getUser() +``` + +### Protect a Netlify Function + +```ts +import { getUser } from '@netlify/identity' +import type { Context } from '@netlify/functions' + +export default async (req: Request, context: Context) => { + const user = await getUser() + if (!user) return new Response('Unauthorized', { status: 401 }) + return Response.json({ id: user.id, email: user.email }) +} +``` + +### Protect an Edge Function + +```ts +import { getUser } from '@netlify/identity' +import type { Context } from '@netlify/edge-functions' + +export default async (req: Request, context: Context) => { + const user = await getUser() + if (!user) return new Response('Unauthorized', { status: 401 }) + return Response.json({ id: user.id, email: user.email }) +} +``` + +## API + +### Functions + +#### `getUser` + +```ts +getUser(): Promise +``` + +Returns the current authenticated user, or `null` if not logged in. Returns the best available normalized `User` from +the current context. When the Identity API is reachable, most persisted and profile fields are populated, but +state-dependent fields (invite, recovery, email-change) may still be `undefined` if the user is not in that state. When +falling back to JWT claims (e.g., Identity API unreachable), only `id`, `email`, `provider`, `name`, `pictureUrl`, +`roles`, `userMetadata`, and `appMetadata` are available. Never throws. + +> **Next.js note:** Calling `getUser()` in a Server Component opts the page into +> [dynamic rendering](https://nextjs.org/docs/app/building-your-application/rendering/server-components#dynamic-rendering) +> because it reads cookies. This is expected and correct for authenticated pages. Next.js handles the internal dynamic +> rendering signal automatically. + +#### `isAuthenticated` + +```ts +isAuthenticated(): Promise +``` + +Returns `true` if a user is currently authenticated. Equivalent to `(await getUser()) !== null`. Never throws. + +#### `getIdentityConfig` + +```ts +getIdentityConfig(): IdentityConfig | null +``` + +Returns the Identity endpoint URL (and operator token on the server), or `null` if Identity is not available. Never +throws. + +#### `getSettings` + +```ts +getSettings(): Promise +``` + +Fetches your project's Identity settings (enabled providers, autoconfirm, signup disabled). + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if the endpoint is unreachable. + +#### `login` + +```ts +login(email: string, password: string): Promise +``` + +Logs in with email and password. Works in both browser and server contexts. + +In the browser, emits a `'login'` event. On the server (Netlify Functions, Edge Functions), calls the Identity API +directly and sets the `nf_jwt` cookie via the Netlify runtime. + +**Throws:** `AuthError` on invalid credentials or network failure. In the browser, `MissingIdentityError` if Identity is +not configured. On the server, `AuthError` if the Netlify Functions runtime is not available. + +#### `signup` + +```ts +signup(email: string, password: string, data?: SignupData): Promise +``` + +Creates a new account. Works in both browser and server contexts. + +If autoconfirm is enabled in your Identity settings, the user is logged in immediately: cookies are set and a `'login'` +event is emitted. If autoconfirm is **disabled** (the default), the user receives a confirmation email and must click +the link before they can log in. In that case, no cookies are set and no auth event is emitted. + +The optional `data` parameter sets user metadata (e.g., `{ full_name: 'Jane Doe' }`), stored in the user's +`user_metadata` field. + +**Throws:** `AuthError` on failure (e.g., email already registered, signup disabled). In the browser, +`MissingIdentityError` if Identity is not configured. On the server, `AuthError` if the Netlify Functions runtime is not +available. + +#### `logout` + +```ts +logout(): Promise +``` + +Logs out the current user and clears the session. Works in both browser and server contexts. + +In the browser, emits a `'logout'` event. On the server, calls the Identity `/logout` endpoint with the JWT from the +`nf_jwt` cookie, then deletes the cookie. Auth cookies are always cleared, even if the server call fails. + +**Throws:** In the browser, `MissingIdentityError` if Identity is not configured. On the server, `AuthError` if the +Netlify Functions runtime is not available. + +#### `oauthLogin` + +```ts +oauthLogin(provider: string): never +``` + +Redirects to an OAuth provider. The page navigates away, so this function never returns normally. Browser only. + +The `provider` argument should be one of the `AuthProvider` values: `'google'`, `'github'`, `'gitlab'`, `'bitbucket'`, +or `'facebook'`. + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if called on the server. + +#### `handleAuthCallback` + +```ts +handleAuthCallback(): Promise +``` + +Processes the URL hash after an OAuth redirect, email confirmation, password recovery, invite acceptance, or email +change. Call on page load. Returns `null` if the hash contains no auth parameters. Browser only. + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if token exchange fails. + +#### `onAuthChange` + +```ts +onAuthChange(callback: AuthCallback): () => void +``` + +Subscribes to auth state changes (login, logout, token refresh, user updates, and recovery). Returns an unsubscribe +function. Also fires on cross-tab session changes. No-op on the server. The `'recovery'` event fires when +`handleAuthCallback()` processes a password recovery token; listen for it to redirect users to a password reset form. + +#### `hydrateSession` + +```ts +hydrateSession(): Promise +``` + +Bootstraps the browser-side session from server-set auth cookies (`nf_jwt`, `nf_refresh`). Returns the hydrated `User`, +or `null` if no auth cookies are present. No-op on the server. + +**When to use:** After a server-side login (e.g., via a Netlify Function or Server Action), the `nf_jwt` cookie is set +but no browser session exists yet. `getUser()` calls `hydrateSession()` automatically, but account operations like +`updateUser()` or `verifyEmailChange()` require a live browser session. Call `hydrateSession()` explicitly if you need +the session ready before calling those operations. + +If a browser session already exists (e.g., from a browser-side login), this is a no-op and returns the existing user. + +```ts +import { hydrateSession, updateUser } from '@netlify/identity' + +// On page load, hydrate the session from server-set cookies +await hydrateSession() + +// Now browser account operations work +await updateUser({ data: { full_name: 'Jane Doe' } }) +``` + +#### `refreshSession` + +```ts +refreshSession(): Promise +``` + +Refreshes an expired or near-expired session. Returns the new access token on success, or `null` if no refresh is needed +or the refresh token is invalid/missing. + +**Browser:** Checks if the current access token is near expiry and refreshes it if needed, syncing the new token to the +`nf_jwt` cookie. Note: the library automatically refreshes tokens in the background after any browser flow that +establishes a session (`login()`, `signup()`, `hydrateSession()`, `handleAuthCallback()`, `confirmEmail()`, +`recoverPassword()`, `acceptInvite()`), so you typically don't need to call this manually. `getUser()` also restarts the +refresh timer when it finds an existing session. Browser-side errors return `null`, not an `AuthError`. + +**Server:** Reads the `nf_jwt` and `nf_refresh` cookies. If the access token is expired or within 60 seconds of expiry, +exchanges the refresh token for a new access token via the Identity `/token` endpoint and updates both cookies on the +response. Call this in framework middleware or at the start of server-side request handlers to ensure the JWT is valid +for downstream processing. + +**Throws:** `AuthError` on network failure or if the Identity endpoint URL cannot be determined. Does **not** throw for +invalid/expired refresh tokens (returns `null` instead). + +```ts +// Example: Astro middleware +import { refreshSession } from '@netlify/identity' + +export async function onRequest(context, next) { + await refreshSession() + return next() +} +``` + +#### `verifyRequestOrigin` + +```ts +verifyRequestOrigin(request: Request, options?: VerifyRequestOriginOptions): void +``` + +CSRF protection helper for server-side endpoints that call `login()`, `signup()`, or `logout()`. Compares the request's +`Origin` header against the request's own origin (or an explicit allowlist via `options.allowedOrigins`) and throws if +they don't match. Server-only. + +The check runs unconditionally on every call: any HTTP method, with or without an `Origin` header. If you don't want the +check on a particular route, don't call the helper there. + +**Throws:** `AuthError` with status `403` when the request has no `Origin` header. `AuthError` with status `403` when +the request's `Origin` is not in the allowed origins. + +See [Security: CSRF protection](#security-csrf-protection) for the full threat model and per-framework guidance. + +#### `requestPasswordRecovery` + +```ts +requestPasswordRecovery(email: string): Promise +``` + +Sends a password recovery email to the given address. + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` on network failure. + +#### `confirmEmail` + +```ts +confirmEmail(token: string): Promise +``` + +Confirms an email address using the token from a confirmation email. Logs the user in on success. + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if the token is invalid or expired. + +#### `acceptInvite` + +```ts +acceptInvite(token: string, password: string): Promise +``` + +Accepts an invite token and sets a password for the new account. Logs the user in on success. + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if the token is invalid or expired. + +#### `verifyEmailChange` + +```ts +verifyEmailChange(token: string): Promise +``` + +Verifies an email change using the token from a verification email. + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if the token is invalid. + +#### `recoverPassword` + +```ts +recoverPassword(token: string, newPassword: string): Promise +``` + +Redeems a recovery token and sets a new password. Logs the user in on success. + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if the token is invalid or expired. + +#### `updateUser` + +```ts +updateUser(updates: UserUpdates): Promise +``` + +Updates the current user's metadata or credentials. Requires an active session. Pass `email` or `password` to change +credentials, or `data` to update user metadata (e.g., `{ data: { full_name: 'New Name' } }`). + +**Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if no user is logged in, or the update +fails. + +### Admin Operations + +The `admin` namespace provides server-only user management functions. Admin methods use the operator token from the +Netlify runtime, which is automatically available in Netlify Functions and Edge Functions. + +Calling any admin method from a browser environment throws an `AuthError`. + +```ts +import { admin } from '@netlify/identity' +``` + +**Example: managing users in a Netlify Function** + +```ts +import { admin } from '@netlify/identity' +import type { Context } from '@netlify/functions' + +export default async (req: Request, context: Context) => { + // List all users + const users = await admin.listUsers() + + // Create a new user (auto-confirmed, no email sent) + const newUser = await admin.createUser({ + email: 'jane@example.com', + password: 'securepassword', + data: { user_metadata: { full_name: 'Jane Doe' } }, + }) + + // Update a user's role + await admin.updateUser(newUser.id, { role: 'editor' }) + + return Response.json({ created: newUser.id, total: users.length }) +} +``` + +#### `admin.listUsers` + +```ts +admin.listUsers(options?: ListUsersOptions): Promise +``` + +Lists all users. Pagination options (`page`, `perPage`) are forwarded as query parameters. + +**Throws:** `AuthError` if called from a browser, or if the operator token is missing. + +#### `admin.getUser` + +```ts +admin.getUser(userId: string): Promise +``` + +Gets a single user by ID. + +**Throws:** `AuthError` if called from a browser, the user is not found, or the operator token is missing. + +#### `admin.createUser` + +```ts +admin.createUser(params: CreateUserParams): Promise +``` + +Creates a new user. The user is auto-confirmed. Optional `data` forwards allowed fields (`role`, `app_metadata`, +`user_metadata`) to the request body. Other keys are silently ignored. `data` cannot override `email`, `password`, or +`confirm`. + +**Throws:** `AuthError` if called from a browser, the email already exists, or the operator token is missing. + +#### `admin.updateUser` + +```ts +admin.updateUser(userId: string, attributes: AdminUserUpdates): Promise +``` + +Updates an existing user by ID. Only typed `AdminUserUpdates` fields are forwarded (e.g., +`{ email: 'new@example.com' }`, `{ role: 'editor' }`). + +**Throws:** `AuthError` if called from a browser, the user is not found, or the update fails. + +#### `admin.deleteUser` + +```ts +admin.deleteUser(userId: string): Promise +``` + +Deletes a user by ID. + +**Throws:** `AuthError` if called from a browser, the user is not found, or the deletion fails. + +### Types + +#### `User` + +```ts +interface User { + id: string + email?: string + confirmedAt?: string + createdAt?: string + updatedAt?: string + role?: string + provider?: AuthProvider + name?: string + pictureUrl?: string + roles?: string[] + invitedAt?: string + confirmationSentAt?: string + recoverySentAt?: string + pendingEmail?: string + emailChangeSentAt?: string + lastSignInAt?: string + userMetadata?: Record + appMetadata?: Record +} +``` + +#### `Settings` + +```ts +interface Settings { + autoconfirm: boolean + disableSignup: boolean + providers: Record +} +``` + +#### `IdentityConfig` + +```ts +interface IdentityConfig { + url: string + token?: string +} +``` + +#### `AuthProvider` + +```ts +type AuthProvider = 'google' | 'github' | 'gitlab' | 'bitbucket' | 'facebook' | 'email' +``` + +#### `UserUpdates` + +```ts +interface UserUpdates { + email?: string + password?: string + data?: Record + [key: string]: unknown +} +``` + +Fields accepted by `updateUser()`. All fields are optional. + +#### `AdminUserUpdates` + +```ts +interface AdminUserUpdates { + email?: string + password?: string + role?: string + confirm?: boolean + app_metadata?: Record + user_metadata?: Record +} +``` + +Fields accepted by `admin.updateUser()`. Unlike `UserUpdates`, admin updates can set `role`, force-confirm a user, and +write to `app_metadata`. Only these typed fields are forwarded. + +#### `SignupData` + +```ts +type SignupData = Record +``` + +User metadata passed as the third argument to `signup()`. Stored in the user's `user_metadata` field. + +#### `AppMetadata` + +```ts +interface AppMetadata { + provider: AuthProvider + roles?: string[] + [key: string]: unknown +} +``` + +#### `ListUsersOptions` + +```ts +interface ListUsersOptions { + page?: number + perPage?: number +} +``` + +Pagination options for `admin.listUsers()`. + +#### `CreateUserParams` + +```ts +interface CreateUserParams { + email: string + password: string + data?: Record +} +``` + +Parameters for `admin.createUser()`. Optional `data` forwards allowed fields (`role`, `app_metadata`, `user_metadata`) +to the request body. Other keys are silently ignored. + +#### `Admin` + +```ts +interface Admin { + listUsers: (options?: ListUsersOptions) => Promise + getUser: (userId: string) => Promise + createUser: (params: CreateUserParams) => Promise + updateUser: (userId: string, attributes: AdminUserUpdates) => Promise + deleteUser: (userId: string) => Promise +} +``` + +The type of the `admin` export. Useful for passing the admin namespace as a dependency. + +#### `AUTH_EVENTS` + +```ts +const AUTH_EVENTS: { + LOGIN: 'login' + LOGOUT: 'logout' + TOKEN_REFRESH: 'token_refresh' + USER_UPDATED: 'user_updated' + RECOVERY: 'recovery' +} +``` + +Constants for auth event names. Use these instead of string literals for type safety and autocomplete. + +| Event | When it fires | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LOGIN` | `login()`, `signup()` (with autoconfirm), `recoverPassword()`, `confirmEmail()`, `acceptInvite()`, `handleAuthCallback()` (OAuth/confirmation), `hydrateSession()` | +| `LOGOUT` | `logout()` | +| `TOKEN_REFRESH` | The library's auto-refresh timer refreshes an expiring access token and syncs the new token to the `nf_jwt` cookie. Fires automatically after any session-establishing flow: `login()`, `signup()`, `hydrateSession()`, `handleAuthCallback()`, `confirmEmail()`, `recoverPassword()`, `acceptInvite()`. `getUser()` also restarts the timer when it finds an existing session. | +| `USER_UPDATED` | `updateUser()`, `verifyEmailChange()`, `handleAuthCallback()` (email change) | +| `RECOVERY` | `handleAuthCallback()` (recovery token only). The user is authenticated but has **not** set a new password yet. Listen for this to redirect to a password reset form. `recoverPassword()` emits `LOGIN` instead because it completes both steps (token redemption + password change). | + +#### `AuthEvent` + +```ts +type AuthEvent = 'login' | 'logout' | 'token_refresh' | 'user_updated' | 'recovery' +``` + +#### `AuthCallback` + +```ts +type AuthCallback = (event: AuthEvent, user: User | null) => void +``` + +#### `CallbackResult` + +```ts +interface CallbackResult { + type: 'oauth' | 'confirmation' | 'recovery' | 'invite' | 'email_change' + user: User | null + token?: string +} +``` + +The `token` field is only present for `invite` callbacks, where the user hasn't set a password yet. Pass `token` to +`acceptInvite(token, password)` to finish. + +For all other types (`oauth`, `confirmation`, `recovery`, `email_change`), the user is logged in directly and `token` is +not set. + +#### `VerifyRequestOriginOptions` + +```ts +interface VerifyRequestOriginOptions { + allowedOrigins?: string[] +} +``` + +Options for [`verifyRequestOrigin`](#verifyrequestorigin). When `allowedOrigins` is set, the list replaces the default +same-origin check, so include the request's own origin if you still want it allowed. Each value is a full origin string +with scheme and host (`'https://example.com'`). + +### Errors + +#### `AuthError` + +```ts +class AuthError extends Error { + status?: number + cause?: unknown +} +``` + +#### `MissingIdentityError` + +```ts +class MissingIdentityError extends Error {} +``` + +Thrown when Identity is not configured in the current environment. + +## Security: CSRF protection + +If you expose server-side `login()`, `signup()`, or `logout()` through an HTTP endpoint, that endpoint needs Cross-Site +Request Forgery (CSRF) protection. The library cannot enforce this itself because it only sees the email and password +arguments handed to it, not the incoming request. + +**Why it matters.** A specific flavor called _login CSRF_ lets an attacker trick a victim's browser into logging into +the attacker's account. The victim then performs actions inside that session (saving payment info, linking third-party +services, uploading content), and the attacker harvests the result later by signing in with the credentials they always +controlled. `SameSite=Lax` cookies do not catch this attack because the session is being created on the victim's +browser, not ridden from an existing one. + +### `verifyRequestOrigin` + +`verifyRequestOrigin(request, options?)` compares the request's `Origin` header against the request's own origin (or an +explicit allowlist) and throws `AuthError` with status 403 on mismatch. Call it at the start of any handler that +performs an auth mutation. + +```ts +// netlify/functions/login.ts +import { login, verifyRequestOrigin } from '@netlify/identity' +import type { Context } from '@netlify/functions' + +export default async (req: Request, context: Context) => { + verifyRequestOrigin(req) + const { email, password } = await req.json() + await login(email, password) + return new Response(null, { status: 302, headers: { Location: '/dashboard' } }) +} +``` + +The helper runs unconditionally on every call. It checks any HTTP method, with or without an `Origin` header. If you +don't want the check on a particular route, don't call the helper there. + +### Custom allowed origins + +By default, the helper accepts only the request's own origin. Pass `allowedOrigins` to allow additional trusted origins +(for example, a separate frontend domain that POSTs to an API on another domain). The list replaces the default, so +include the request's own origin if you still want it allowed: + +```ts +verifyRequestOrigin(req, { + allowedOrigins: ['https://app.example.com', 'https://www.example.com'], +}) +``` + +### When to call the helper + +Some frameworks check the request's `Origin` on state-changing requests by default; others don't. Check your framework's +documentation. If same-origin enforcement is already on by default for the endpoint where you invoke `login()` / +`signup()` / `logout()`, calling `verifyRequestOrigin` yourself is redundant. If it isn't, call +`verifyRequestOrigin(request)` at the start of the handler before invoking the auth function. + +## Framework integration + +### Recommended pattern for SSR frameworks + +For SSR frameworks (Next.js, Remix, Astro, TanStack Start), the recommended pattern is: + +- **Browser-side** for auth mutations: `login()`, `signup()`, `logout()`, `oauthLogin()` +- **Server-side** for reading auth state: `getUser()`, `getSettings()`, `getIdentityConfig()` + +Browser-side auth mutations call the Identity API directly from the browser, set the `nf_jwt` cookie, and emit +`onAuthChange` events. This keeps the client UI in sync immediately. Server-side reads work because the cookie is sent +with every request. + +The library also supports server-side mutations (`login()`, `signup()`, `logout()` inside Netlify Functions), but these +require the Netlify Functions runtime to set cookies. After a server-side mutation, you need a full page navigation so +the browser sends the new cookie. + +### Next.js (App Router) + +**Server Actions return results; the client handles navigation:** + +```tsx +// app/actions.ts +'use server' +import { login, logout } from '@netlify/identity' + +export async function loginAction(formData: FormData) { + const email = formData.get('email') as string + const password = formData.get('password') as string + await login(email, password) + return { success: true } +} + +export async function logoutAction() { + await logout() + return { success: true } +} +``` + +```tsx +// app/login/page.tsx +'use client' +import { loginAction } from '../actions' + +export default function LoginPage() { + async function handleSubmit(formData: FormData) { + const result = await loginAction(formData) + if (result.success) { + window.location.href = '/dashboard' // full page load + } + } + + return
...
+} +``` + +```tsx +// app/dashboard/page.tsx +import { getUser } from '@netlify/identity' +import { redirect } from 'next/navigation' + +export default async function Dashboard() { + const user = await getUser() + if (!user) redirect('/login') + + return

Hello, {user.email}

+} +``` + +Use `window.location.href` instead of Next.js `redirect()` after server-side auth mutations. Next.js `redirect()` +triggers a soft navigation via the Router, which may not include the newly-set auth cookie. A full page load ensures the +cookie is sent and the server sees the updated auth state. Reading auth state with `getUser()` in Server Components +works normally, and `redirect()` is fine for auth gates (where no cookie was just set). + +### Remix + +**Login with Action (server-side pattern):** + +```tsx +// app/routes/login.tsx +import { login, verifyRequestOrigin } from '@netlify/identity' +import { redirect, json } from '@remix-run/node' +import type { ActionFunctionArgs } from '@remix-run/node' + +export async function action({ request }: ActionFunctionArgs) { + verifyRequestOrigin(request) + const formData = await request.formData() + const email = formData.get('email') as string + const password = formData.get('password') as string + + try { + await login(email, password) + return redirect('/dashboard') + } catch (error) { + return json({ error: (error as Error).message }, { status: 400 }) + } +} +``` + +```tsx +// app/routes/dashboard.tsx +import { getUser } from '@netlify/identity' +import { redirect } from '@remix-run/node' + +export async function loader() { + const user = await getUser() + if (!user) return redirect('/login') + return { user } +} +``` + +Remix `redirect()` works after server-side `login()` because Remix actions return real HTTP responses. The browser +receives a 302 with the `Set-Cookie` header already applied, so the next request includes the auth cookie. This is +different from Next.js, where `redirect()` in a Server Action triggers a client-side (soft) navigation that may not +include newly-set cookies. + +> The example calls [`verifyRequestOrigin`](#verifyrequestorigin) at the top of the action. See +> [Security: CSRF protection](#security-csrf-protection) for when this is needed. + +### TanStack Start + +**Login from the browser (recommended):** + +```tsx +// app/server/auth.ts - server functions for reads only +import { createServerFn } from '@tanstack/react-start' +import { getUser } from '@netlify/identity' + +export const getServerUser = createServerFn({ method: 'GET' }).handler(async () => { + const user = await getUser() + return user ?? null +}) +``` + +```tsx +// app/routes/login.tsx - browser-side auth for mutations +import { login, signup, onAuthChange } from '@netlify/identity' +import { getServerUser } from '~/server/auth' + +export const Route = createFileRoute('/login')({ + beforeLoad: async () => { + const user = await getServerUser() + if (user) throw redirect({ to: '/dashboard' }) + }, + component: Login, +}) + +function Login() { + const handleLogin = async (email: string, password: string) => { + await login(email, password) // browser-side: sets cookie + localStorage + window.location.href = '/dashboard' + } + // ... +} +``` + +```tsx +// app/routes/dashboard.tsx +import { logout } from '@netlify/identity' +import { getServerUser } from '~/server/auth' + +export const Route = createFileRoute('/dashboard')({ + beforeLoad: async () => { + const user = await getServerUser() + if (!user) throw redirect({ to: '/login' }) + }, + loader: async () => { + const user = await getServerUser() + return { user: user! } + }, + component: Dashboard, +}) + +function Dashboard() { + const { user } = Route.useLoaderData() + + const handleLogout = async () => { + await logout() // browser-side: clears cookie + localStorage + window.location.href = '/' + } + // ... +} +``` + +Use `window.location.href` instead of TanStack Router's `navigate()` after auth changes. This ensures the browser sends +the updated cookie on the next request. + +### Astro (SSR) + +**Login via API endpoint (server-side pattern):** + +```ts +// src/pages/api/login.ts +import type { APIRoute } from 'astro' +import { login } from '@netlify/identity' + +export const POST: APIRoute = async ({ request }) => { + const { email, password } = await request.json() + + try { + await login(email, password) + return new Response(null, { + status: 302, + headers: { Location: '/dashboard' }, + }) + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 400 }) + } +} +``` + +```astro +--- +// src/pages/dashboard.astro +import { getUser } from '@netlify/identity' + +const user = await getUser() +if (!user) return Astro.redirect('/login') +--- +

Hello, {user.email}

+``` + +### SvelteKit + +**Login from the browser (recommended):** + +```svelte + + + +
+ + + + {#if error}

{error}

{/if} +
+``` + +```ts +// src/routes/dashboard/+page.server.ts +import { getUser } from '@netlify/identity' +import { redirect } from '@sveltejs/kit' + +export async function load() { + const user = await getUser() + if (!user) redirect(302, '/login') + return { user } +} +``` + +### Handling OAuth callbacks in SPAs + +All SPA frameworks need a callback handler that runs on page load to process OAuth redirects, email confirmations, and +password recovery tokens. Use a **wrapper component** that blocks page content while processing tokens. This prevents a +flash of unauthenticated content that occurs when the page renders before the callback completes. + +```tsx +// React component (works with Next.js, Remix, TanStack Start) +import { useEffect, useState } from 'react' +import { handleAuthCallback } from '@netlify/identity' + +const AUTH_HASH_PATTERN = /^#(confirmation_token|recovery_token|invite_token|email_change_token|access_token)=/ + +export function CallbackHandler({ children }: { children: React.ReactNode }) { + const [processing, setProcessing] = useState( + () => typeof window !== 'undefined' && AUTH_HASH_PATTERN.test(window.location.hash), + ) + const [error, setError] = useState(null) + + useEffect(() => { + if (!window.location.hash || !AUTH_HASH_PATTERN.test(window.location.hash)) return + + handleAuthCallback() + .then((result) => { + if (!result) { + setProcessing(false) + return + } + if (result.type === 'invite') { + window.location.href = `/accept-invite?token=${result.token}` + } else if (result.type === 'recovery') { + window.location.href = '/reset-password' + } else { + window.location.href = '/dashboard' + } + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Callback failed') + setProcessing(false) + }) + }, []) + + if (error) return
Auth error: {error}
+ if (processing) return
Confirming your account...
+ return <>{children} +} +``` + +Wrap your page content with this component in your **root layout** so it runs on every page: + +```tsx +// Root layout + + {/* or {children} in Next.js */} + +``` + +If you only mount it on a `/callback` route, OAuth redirects and email confirmation links that land on other pages will +not be processed. + +## Guides + +### React `useAuth` hook + +The library is framework-agnostic, but here's a simple React hook for keeping components in sync with auth state: + +```tsx +import { useState, useEffect } from 'react' +import { getUser, onAuthChange } from '@netlify/identity' +import type { User } from '@netlify/identity' + +export function useAuth() { + const [user, setUser] = useState(null) + + useEffect(() => { + getUser().then(setUser) + return onAuthChange((_event, user) => setUser(user)) + }, []) + + return user +} +``` + +```tsx +function NavBar() { + const user = useAuth() + return user ?

Hello, {user.name}

: Log in +} +``` + +### Listening for auth changes + +Use `onAuthChange` to keep your UI in sync with auth state. It fires on login, logout, token refresh, user updates, and +recovery. It also detects session changes in other browser tabs (via `localStorage`). + +```ts +import { onAuthChange, AUTH_EVENTS } from '@netlify/identity' + +const unsubscribe = onAuthChange((event, user) => { + switch (event) { + case AUTH_EVENTS.LOGIN: + console.log('Logged in:', user?.email) + break + case AUTH_EVENTS.LOGOUT: + console.log('Logged out') + break + case AUTH_EVENTS.TOKEN_REFRESH: + console.log('Token refreshed for:', user?.email) + break + case AUTH_EVENTS.USER_UPDATED: + console.log('User updated:', user?.email) + break + case AUTH_EVENTS.RECOVERY: + console.log('Recovery login:', user?.email) + // Redirect to password reset form, then call updateUser({ password }) + break + } +}) + +// Later, to stop listening: +unsubscribe() +``` + +On the server, `onAuthChange` is a no-op and the returned unsubscribe function does nothing. + +### OAuth login + +OAuth login is a two-step flow: redirect the user to the provider, then process the callback when they return. + +**Step by step:** + +```ts +import { oauthLogin, handleAuthCallback } from '@netlify/identity' + +// 1. Kick off the OAuth flow (e.g., from a "Sign in with GitHub" button). +// This navigates away from the page and does not return. +oauthLogin('github') +``` + +```ts +// 2. On page load, handle the redirect back from the provider. +const result = await handleAuthCallback() + +if (result?.type === 'oauth') { + console.log('Logged in via OAuth:', result.user?.email) +} +``` + +`handleAuthCallback()` exchanges the token in the URL hash, logs the user in, clears the hash, and emits an auth event +via `onAuthChange` (`'login'` for OAuth/confirmation, `'recovery'` for password recovery). + +### Password recovery + +Password recovery is a two-step flow. The library handles the token exchange automatically via `handleAuthCallback()`, +which logs the user in and returns `{type: 'recovery', user}`. A `'recovery'` event (not `'login'`) is emitted via +`onAuthChange`, so event-based listeners can also detect this flow. You then show a "set new password" form and call +`updateUser()` to save it. + +**Step by step:** + +```ts +import { requestPasswordRecovery, handleAuthCallback, updateUser } from '@netlify/identity' + +// 1. Send recovery email (e.g., from a "forgot password" form) +await requestPasswordRecovery('jane@example.com') + +// 2-3. On page load, handle the callback +const result = await handleAuthCallback() + +if (result?.type === 'recovery') { + // 4. User is now logged in. Show your "set new password" form. + // When they submit: + const newPassword = document.getElementById('new-password').value + await updateUser({ password: newPassword }) +} +``` + +If you use the event-based pattern instead of checking `result.type`, listen for the `'recovery'` event: + +```ts +import { onAuthChange, AUTH_EVENTS } from '@netlify/identity' + +onAuthChange((event, user) => { + if (event === AUTH_EVENTS.RECOVERY) { + // Redirect to password reset form. + // The user is authenticated, so call updateUser({ password }) to set the new password. + } +}) +``` + +### Invite acceptance + +When an admin invites a user, they receive an email with an invite link. Clicking it redirects to your site with an +`invite_token` in the URL hash. Unlike other callback types, the user is not logged in automatically because they need +to set a password first. + +**Step by step:** + +```ts +import { handleAuthCallback, acceptInvite } from '@netlify/identity' + +// 1. On page load, handle the callback. +const result = await handleAuthCallback() + +if (result?.type === 'invite' && result.token) { + // 2. The user is NOT logged in yet. Show a "set your password" form. + // When they submit: + const password = document.getElementById('password').value + const user = await acceptInvite(result.token, password) + console.log('Account created:', user.email) +} +``` + +### Session lifetime + +Sessions are managed by Netlify Identity on the server side. The library stores two cookies: + +- **`nf_jwt`**: A short-lived JWT access token (default: 1 hour). +- **`nf_refresh`**: A long-lived refresh token used to obtain new access tokens without re-authenticating. + +**Browser auto-refresh:** After any session-establishing flow (`login()`, `signup()`, `hydrateSession()`, +`handleAuthCallback()`, `confirmEmail()`, `recoverPassword()`, `acceptInvite()`), the library automatically schedules a +background refresh 60 seconds before the access token expires. `getUser()` also restarts the refresh timer when it finds +an existing session (e.g., after a page reload). When the refresh fires, it obtains a new access token, syncs it to the +`nf_jwt` cookie, and emits a `TOKEN_REFRESH` event. This keeps the cookie fresh as long as the user has the tab open. If +the refresh fails (e.g., the refresh token was revoked), the timer stops and the user will need to log in again. + +**Server-side refresh:** On the server, the access token in the `nf_jwt` cookie is validated as-is. If it has expired +and no refresh happens, `getUser()` returns `null`. To handle this, call `refreshSession()` in your framework middleware +or request handler. This checks if the token is near expiry, exchanges the refresh token for a new one, and updates the +cookies on the response. + +Session lifetime is configured in your Netlify Identity settings, not in this library. + +### Caching and authenticated content + +Pages that display user-specific data (names, emails, roles, account settings) should not be served from a shared cache. +If a cache stores an authenticated response and serves it to a different user, that user sees someone else's data. This +applies to any authentication system, not just Netlify Identity. + +**Next.js App Router** has multiple caching layers that are active by default: + +- **Static rendering:** Server Components are statically rendered at build time unless they call a + [Dynamic API](https://nextjs.org/docs/app/guides/caching#dynamic-rendering) like `cookies()`. This library's + `getUser()` already calls `headers()` internally to opt the route into dynamic rendering, but if you check auth state + without calling `getUser()` (e.g., reading the `nf_jwt` cookie directly), the page may still be statically cached. + Always use `getUser()` rather than reading cookies directly. +- **ISR (Incremental Static Regeneration):** Do not use ISR for pages that display user-specific content. ISR + regenerates the page for the first visitor after the revalidation window and caches the result for all subsequent + visitors. +- **`use cache` / `unstable_cache`:** These directives cannot access `cookies()` or `headers()` directly. If you need to + cache part of an authenticated page, read cookies outside the cache scope and pass relevant values as arguments. + +> **Note:** Next.js caching defaults have changed across versions. For example, +> [Next.js 15 changed `fetch` requests, `GET` Route Handlers, and the client Router Cache to be uncached by default](https://nextjs.org/blog/next-15#caching-semantics), +> reversing the previous opt-out model. Check the [caching guide](https://nextjs.org/docs/app/guides/caching) for your +> specific Next.js version. + +**Other SSR frameworks (Remix, Astro, SvelteKit, TanStack Start):** These frameworks do not cache SSR responses by +default. If you add caching headers to improve performance, exclude routes that call `getUser()` or read auth cookies. + +## License + +MIT diff --git a/packages/identity/prod/package.json b/packages/identity/prod/package.json new file mode 100644 index 00000000..dfb1f8a4 --- /dev/null +++ b/packages/identity/prod/package.json @@ -0,0 +1,72 @@ +{ + "name": "@netlify/identity", + "version": "1.0.0", + "type": "module", + "engines": { + "node": ">=18.0.0" + }, + "description": "Headless auth functions for Netlify Identity. Import { login, getUser } and call them. No init, no class, no UI.", + "main": "./dist/main.cjs", + "module": "./dist/main.js", + "types": "./dist/main.d.ts", + "exports": { + ".": { + "require": { + "types": "./dist/main.d.cts", + "default": "./dist/main.cjs" + }, + "import": { + "types": "./dist/main.d.ts", + "default": "./dist/main.js" + }, + "default": { + "types": "./dist/main.d.ts", + "default": "./dist/main.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "tsup-node", + "dev": "tsup-node --watch", + "prepack": "npm run build", + "test": "vitest run", + "test:dev": "vitest", + "publint": "npx -y publint --strict" + }, + "keywords": [ + "netlify", + "identity", + "authentication", + "jwt", + "auth", + "oauth", + "login", + "signup", + "gotrue", + "serverless", + "edge-functions" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/netlify/primitives.git", + "directory": "packages/identity/prod" + }, + "bugs": { + "url": "https://github.com/netlify/primitives/issues" + }, + "author": "Netlify Inc.", + "devDependencies": { + "@types/node": "^22.0.0", + "jsdom": "^28.1.0", + "tsup": "^8.0.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "gotrue-js": "^1.0.1" + } +} diff --git a/packages/identity/prod/src/account.ts b/packages/identity/prod/src/account.ts new file mode 100644 index 00000000..074067f6 --- /dev/null +++ b/packages/identity/prod/src/account.ts @@ -0,0 +1,165 @@ +import type { UserData, User as GoTrueUser } from 'gotrue-js' + +import type { UserUpdates, GoTrueErrorBody } from './types.js' +import type { User } from './user.js' +import { toUser } from './user.js' +import { getClient, isBrowser, IDENTITY_PATH } from './environment.js' +import { persistSession, hydrateSession } from './auth.js' +import { AUTH_EVENTS, emitAuthEvent } from './events.js' +import { AuthError } from './errors.js' +import { startTokenRefresh } from './refresh.js' + +/** + * Returns the current Identity user, attempting hydration from cookies if + * no in-memory session exists. Throws if no user can be resolved. + */ +const resolveCurrentUser = async (): Promise => { + const client = getClient() + + let currentUser = client.currentUser() + if (!currentUser && isBrowser()) { + try { + await hydrateSession() + } catch { + // hydration failed (e.g. expired cookie, network error) — fall through + } + currentUser = client.currentUser() + } + if (!currentUser) throw new AuthError('No user is currently logged in') + + return currentUser +} + +/** + * Sends a password recovery email to the given address. + * + * @throws {AuthError} On network failure or if the request is rejected. + */ +export const requestPasswordRecovery = async (email: string): Promise => { + const client = getClient() + + try { + await client.requestPasswordRecovery(email) + } catch (error) { + throw AuthError.from(error) + } +} + +/** + * Redeems a recovery token and sets a new password. Logs the user in on success. + * + * @throws {AuthError} If the token is invalid, expired, or the update fails. + */ +export const recoverPassword = async (token: string, newPassword: string): Promise => { + const client = getClient() + + try { + const gotrueUser = await client.recover(token, persistSession) + const updatedUser = await gotrueUser.update({ password: newPassword }) + const user = toUser(updatedUser) + startTokenRefresh() + // Emits LOGIN because the recovery is fully complete + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + return user + } catch (error) { + throw AuthError.from(error) + } +} + +/** + * Confirms an email address using the token from a confirmation email. Logs the user in on success. + * + * @throws {AuthError} If the token is invalid or expired. + */ +export const confirmEmail = async (token: string): Promise => { + const client = getClient() + + try { + const gotrueUser = await client.confirm(token, persistSession) + const user = toUser(gotrueUser) + startTokenRefresh() + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + return user + } catch (error) { + throw AuthError.from(error) + } +} + +/** + * Accepts an invite token and sets a password for the new account. Logs the user in on success. + * + * @throws {AuthError} If the token is invalid or expired. + */ +export const acceptInvite = async (token: string, password: string): Promise => { + const client = getClient() + + try { + const gotrueUser = await client.acceptInvite(token, password, persistSession) + const user = toUser(gotrueUser) + startTokenRefresh() + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + return user + } catch (error) { + throw AuthError.from(error) + } +} + +/** + * Verifies an email change using the token from a verification email. + * Auto-hydrates from auth cookies if no browser session exists. Browser only. + * + * @throws {AuthError} If called on the server, no user is logged in, or the token is invalid. + */ +export const verifyEmailChange = async (token: string): Promise => { + if (!isBrowser()) throw new AuthError('verifyEmailChange() is only available in the browser') + + const currentUser = await resolveCurrentUser() + + try { + const jwt = (await currentUser.jwt()) as string + const identityUrl = `${window.location.origin}${IDENTITY_PATH}` + + const res = await fetch(`${identityUrl}/user`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ email_change_token: token }), + }) + + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as GoTrueErrorBody + throw new AuthError(errorBody.msg ?? `Email change verification failed (${String(res.status)})`, res.status) + } + + const userData = (await res.json()) as UserData + const user = toUser(userData) + emitAuthEvent(AUTH_EVENTS.USER_UPDATED, user) + return user + } catch (error) { + if (error instanceof AuthError) throw error + throw AuthError.from(error) + } +} + +/** + * Updates the current user's email, password, or user metadata. + * Auto-hydrates from auth cookies if no browser session exists. + * + * @param updates - Fields to update. Pass `email` or `password` to change credentials, + * or `data` to update user metadata (e.g., `{ data: { full_name: 'New Name' } }`). + * @throws {AuthError} If no user is logged in or the update fails. + */ +export const updateUser = async (updates: UserUpdates): Promise => { + const currentUser = await resolveCurrentUser() + + try { + const updatedUser = await currentUser.update(updates) + const user = toUser(updatedUser) + emitAuthEvent(AUTH_EVENTS.USER_UPDATED, user) + return user + } catch (error) { + throw AuthError.from(error) + } +} diff --git a/packages/identity/prod/src/admin.ts b/packages/identity/prod/src/admin.ts new file mode 100644 index 00000000..75637905 --- /dev/null +++ b/packages/identity/prod/src/admin.ts @@ -0,0 +1,261 @@ +import type { UserData } from 'gotrue-js' + +import { isBrowser, getIdentityContext } from './environment.js' +import { AuthError } from './errors.js' +import { fetchWithTimeout } from './fetch.js' +import type { AdminUserUpdates, CreateUserParams, GoTrueErrorBody, ListUsersOptions } from './types.js' +import { toUser, type User } from './user.js' + +const SERVER_ONLY_MESSAGE = + 'Admin operations are server-only. Call admin methods from a Netlify Function or Edge Function, not from browser code.' + +/** + * Validates that a userId is a valid UUID and returns a URL-safe version. + * Identity user IDs are always UUIDs; rejecting anything else prevents + * path traversal (e.g., "../../settings") from reaching unintended endpoints. + */ +const sanitizeUserId = (userId: string): string => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(userId)) { + throw new AuthError('User ID is not a valid UUID') + } + return encodeURIComponent(userId) +} + +/** Throws if called in a browser environment. */ +const assertServer = (): void => { + if (isBrowser()) { + throw new AuthError(SERVER_ONLY_MESSAGE) + } +} + +/** + * Returns the operator token and Identity URL for server-side admin requests. + * @throws {AuthError} If the Identity endpoint URL or operator token is unavailable. + */ +const getAdminAuth = (): { url: string; token: string } => { + const ctx = getIdentityContext() + if (!ctx?.url) { + throw new AuthError('Could not determine the Identity endpoint URL on the server') + } + if (!ctx.token) { + throw new AuthError('Admin operations require an operator token (only available in Netlify Functions)') + } + return { url: ctx.url, token: ctx.token } +} + +/** + * Makes an authenticated admin request to the Identity API on the server. + * @throws {AuthError} If the request fails or the Identity API returns a non-OK status. + */ +const adminFetch = async (path: string, options: RequestInit = {}): Promise => { + const { url, token } = getAdminAuth() + let res: Response + try { + res = await fetchWithTimeout(`${url}${path}`, { + ...options, + headers: { + ...(options.headers as Record | undefined), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }) + } catch (error) { + throw new AuthError((error as Error).message, undefined, { cause: error }) + } + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})) + throw new AuthError( + (errorBody as GoTrueErrorBody).msg ?? `Admin request failed (${String(res.status)})`, + res.status, + ) + } + return res +} + +/** + * Lists all users. Server-only. + * + * Calls `GET /admin/users` with the operator token. Pagination + * options (`page`, `perPage`) are forwarded as query parameters. + * + * @throws {AuthError} If called from a browser, or if the operator token is missing. + */ +const listUsers = async (options?: ListUsersOptions): Promise => { + assertServer() + + const params = new URLSearchParams() + if (options?.page != null) params.set('page', String(options.page)) + if (options?.perPage != null) params.set('per_page', String(options.perPage)) + const query = params.toString() + const path = `/admin/users${query ? `?${query}` : ''}` + + const res = await adminFetch(path) + const body = (await res.json()) as { users: UserData[] } + return body.users.map(toUser) +} + +/** + * Gets a single user by ID. Server-only. + * + * Calls `GET /admin/users/:id` with the operator token. + * + * @throws {AuthError} If called from a browser, the user is not found, + * or the operator token is missing. + */ +const getUser = async (userId: string): Promise => { + assertServer() + const sanitizedUserId = sanitizeUserId(userId) + const res = await adminFetch(`/admin/users/${sanitizedUserId}`) + const userData = (await res.json()) as UserData + return toUser(userData) +} + +/** + * Creates a new user. The user is auto-confirmed (no confirmation email is sent). + * Server-only. + * + * The optional `data` fields are forwarded as top-level attributes in the Identity API + * request body. Accepted fields: `role`, `app_metadata`, `user_metadata`. + * Any other keys in `data` are silently ignored. `data` cannot override `email`, + * `password`, or `confirm`. + * + * Calls `POST /admin/users` with the operator token. + * + * @throws {AuthError} If called from a browser, the email already exists, + * or the operator token is missing. + */ +const createUser = async (params: CreateUserParams): Promise => { + assertServer() + + const body: Record = { + email: params.email, + password: params.password, + confirm: true, + } + + if (params.data) { + const allowedKeys = ['role', 'app_metadata', 'user_metadata'] as const + for (const key of allowedKeys) { + if (key in params.data) { + body[key] = params.data[key] + } + } + } + + const res = await adminFetch('/admin/users', { + method: 'POST', + body: JSON.stringify(body), + }) + const userData = (await res.json()) as UserData + return toUser(userData) +} + +/** + * Updates an existing user by ID. Server-only. + * + * Calls `PUT /admin/users/:id` with the operator token. + * + * @throws {AuthError} If called from a browser, the user is not found, + * the update fails, or the operator token is missing. + */ +const updateUser = async (userId: string, attributes: AdminUserUpdates): Promise => { + assertServer() + const sanitizedUserId = sanitizeUserId(userId) + + const body: Record = {} + const allowedKeys = ['email', 'password', 'role', 'confirm', 'app_metadata', 'user_metadata'] as const + for (const key of allowedKeys) { + if (key in attributes) { + body[key] = attributes[key] + } + } + + const res = await adminFetch(`/admin/users/${sanitizedUserId}`, { + method: 'PUT', + body: JSON.stringify(body), + }) + const userData = (await res.json()) as UserData + return toUser(userData) +} + +/** + * Deletes a user by ID. Server-only. + * + * Calls `DELETE /admin/users/:id` with the operator token. + * + * @throws {AuthError} If called from a browser, the user is not found, + * the deletion fails, or the operator token is missing. + */ +const deleteUser = async (userId: string): Promise => { + assertServer() + const sanitizedUserId = sanitizeUserId(userId) + await adminFetch(`/admin/users/${sanitizedUserId}`, { method: 'DELETE' }) +} + +/** + * The admin namespace for privileged user management operations. + * All methods are server-only and require the operator token + * (automatically available in Netlify Functions and Edge Functions). + * + * Calling any admin method from a browser environment throws an `AuthError`. + */ +export interface Admin { + /** + * Lists all users. Server-only. + * + * Calls `GET /admin/users` with the operator token. Pagination + * options (`page`, `perPage`) are forwarded as query parameters. + * + * @throws {AuthError} If called from a browser, or if the operator token is missing. + */ + listUsers: (options?: ListUsersOptions) => Promise + + /** + * Gets a single user by ID. Server-only. + * + * Calls `GET /admin/users/:id` with the operator token. + * + * @throws {AuthError} If called from a browser, the user is not found, + * or the operator token is missing. + */ + getUser: (userId: string) => Promise + + /** + * Creates a new user. The user is auto-confirmed (no confirmation email is sent). + * Server-only. + * + * The optional `data` fields are forwarded as top-level attributes in the Identity API + * request body. Accepted fields: `role`, `app_metadata`, `user_metadata`. + * Any other keys in `data` are silently ignored. `data` cannot override `email`, + * `password`, or `confirm`. + * + * Calls `POST /admin/users` with the operator token. + * + * @throws {AuthError} If called from a browser, the email already exists, + * or the operator token is missing. + */ + createUser: (params: CreateUserParams) => Promise + + /** + * Updates an existing user by ID. Server-only. + * + * Calls `PUT /admin/users/:id` with the operator token. + * + * @throws {AuthError} If called from a browser, the user is not found, + * the update fails, or the operator token is missing. + */ + updateUser: (userId: string, attributes: AdminUserUpdates) => Promise + + /** + * Deletes a user by ID. Server-only. + * + * Calls `DELETE /admin/users/:id` with the operator token. + * + * @throws {AuthError} If called from a browser, the user is not found, + * the deletion fails, or the operator token is missing. + */ + deleteUser: (userId: string) => Promise +} + +export const admin: Admin = { listUsers, getUser, createUser, updateUser, deleteUser } diff --git a/packages/identity/prod/src/auth.ts b/packages/identity/prod/src/auth.ts new file mode 100644 index 00000000..ee2e75dc --- /dev/null +++ b/packages/identity/prod/src/auth.ts @@ -0,0 +1,486 @@ +import type GoTrue from 'gotrue-js' +import type { UserData } from 'gotrue-js' +import type { AuthProvider, NetlifyCookies, SignupData, TokenResponse, GoTrueErrorBody } from './types.js' +import { toUser, decodeJwtPayload } from './user.js' + +import { getClient, getIdentityContext, isBrowser, IDENTITY_PATH } from './environment.js' +import { + getCookie, + setAuthCookies, + deleteAuthCookies, + setBrowserAuthCookies, + deleteBrowserAuthCookies, + NF_JWT_COOKIE, + NF_REFRESH_COOKIE, +} from './cookies.js' +import { AuthError } from './errors.js' +import { AUTH_EVENTS, emitAuthEvent } from './events.js' +import { startTokenRefresh, stopTokenRefresh } from './refresh.js' +import { fetchWithTimeout } from './fetch.js' + +const getCookies = (): NetlifyCookies => { + const cookies = globalThis.Netlify?.context?.cookies + if (!cookies) { + throw new AuthError('Server-side auth requires Netlify Functions runtime') + } + return cookies +} + +const getServerIdentityUrl = (): string => { + const ctx = getIdentityContext() + if (!ctx?.url) { + throw new AuthError('Could not determine the Identity endpoint URL on the server') + } + return ctx.url +} + +/** Persist the session to localStorage so it survives page reloads. */ +export const persistSession = true + +/** + * Logs in with email and password. Works in both browser and server contexts. + * + * On success, sets `nf_jwt` and `nf_refresh` cookies and returns the authenticated {@link User}. + * In the browser, also emits a `'login'` event via {@link onAuthChange}. + * + * @throws {AuthError} On invalid credentials, network failure, or missing Netlify runtime. + * + * @remarks + * In Next.js server actions, call `redirect()` **after** `login()` returns, not inside a + * try/catch. Next.js implements `redirect()` by throwing a special error; wrapping it in + * try/catch will swallow the redirect. + * + * **Server-side CSRF:** When called from a server endpoint, the endpoint must have CSRF + * protection. If your framework does not check the request's `Origin` by default, call + * {@link verifyRequestOrigin} at the start of the handler before invoking `login()`. + * + * @example + * ```ts + * // Next.js server action + * const user = await login(email, password) + * redirect('/dashboard') // after login, not inside try/catch + * ``` + */ +export const login = async (email: string, password: string): Promise => { + if (!isBrowser()) { + const identityUrl = getServerIdentityUrl() + const cookies = getCookies() + + const body = new URLSearchParams({ + grant_type: 'password', + username: email, + password, + }) + + let res: Response + try { + res = await fetchWithTimeout(`${identityUrl}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + } catch (error) { + throw AuthError.from(error) + } + + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as GoTrueErrorBody + throw new AuthError( + errorBody.msg ?? errorBody.error_description ?? `Login failed (${String(res.status)})`, + res.status, + ) + } + + const data = (await res.json()) as TokenResponse + const accessToken = data.access_token + + let userRes: Response + try { + userRes = await fetchWithTimeout(`${identityUrl}/user`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + } catch (error) { + throw AuthError.from(error) + } + + if (!userRes.ok) { + const errorBody = (await userRes.json().catch(() => ({}))) as GoTrueErrorBody + throw new AuthError(errorBody.msg ?? `Failed to fetch user data (${String(userRes.status)})`, userRes.status) + } + + const userData = (await userRes.json()) as UserData + const user = toUser(userData) + + setAuthCookies(cookies, accessToken, data.refresh_token) + + return user + } + + const client = getClient() + + try { + const gotrueUser = await client.login(email, password, persistSession) + const jwt = await gotrueUser.jwt() + setBrowserAuthCookies(jwt, gotrueUser.tokenDetails()?.refresh_token) + const user = toUser(gotrueUser) + startTokenRefresh() + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + return user + } catch (error) { + throw AuthError.from(error) + } +} + +/** + * Creates a new account. Works in both browser and server contexts. + * + * If autoconfirm is enabled in your Identity settings, the user is logged in immediately: + * cookies are set and a `'login'` event is emitted. If autoconfirm is **disabled** (the default), + * the user receives a confirmation email and must click the link before they can log in. + * In that case, no cookies are set and no auth event is emitted. + * + * @throws {AuthError} On duplicate email, validation failure, network error, or missing Netlify runtime. + * + * @remarks + * **Server-side CSRF:** When called from a server endpoint, the endpoint must have CSRF + * protection. If your framework does not check the request's `Origin` by default, call + * {@link verifyRequestOrigin} at the start of the handler before invoking `signup()`. + */ +export const signup = async (email: string, password: string, data?: SignupData): Promise => { + if (!isBrowser()) { + const identityUrl = getServerIdentityUrl() + const cookies = getCookies() + + let res: Response + try { + res = await fetchWithTimeout(`${identityUrl}/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, data }), + }) + } catch (error) { + throw AuthError.from(error) + } + + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as GoTrueErrorBody + throw new AuthError(errorBody.msg ?? `Signup failed (${String(res.status)})`, res.status) + } + + const responseData = (await res.json()) as UserData & Partial + const user = toUser(responseData) + + if (responseData.confirmed_at) { + const accessToken = responseData.access_token + if (accessToken) { + setAuthCookies(cookies, accessToken, responseData.refresh_token) + } + } + + return user + } + + const client = getClient() + + try { + const response = await client.signup(email, password, data) + const user = toUser(response as UserData) + if (response.confirmed_at) { + const jwt = await (response as { jwt?: () => Promise }).jwt?.() + if (jwt) { + const refreshToken = (response as { tokenDetails?: () => { refresh_token: string } | null }).tokenDetails?.() + ?.refresh_token + setBrowserAuthCookies(jwt, refreshToken) + } + startTokenRefresh() + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + } + return user + } catch (error) { + throw AuthError.from(error) + } +} + +/** + * Logs out the current user and clears the session. Works in both browser and server contexts. + * + * Always deletes `nf_jwt` and `nf_refresh` cookies, even if the server-side token + * invalidation request fails. In the browser, emits a `'logout'` event via {@link onAuthChange}. + * + * @throws {AuthError} On missing Netlify runtime (server) or logout failure (browser). + * + * @remarks + * **Server-side CSRF:** When called from a server endpoint, the endpoint must have CSRF + * protection. If your framework does not check the request's `Origin` by default, call + * {@link verifyRequestOrigin} at the start of the handler before invoking `logout()`. + */ +export const logout = async (): Promise => { + if (!isBrowser()) { + const identityUrl = getServerIdentityUrl() + const cookies = getCookies() + + const jwt = cookies.get(NF_JWT_COOKIE) + if (jwt) { + try { + await fetchWithTimeout(`${identityUrl}/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + }) + } catch { + // Best-effort: token invalidation may fail, but we always clear cookies below + } + } + + deleteAuthCookies(cookies) + return + } + + const client = getClient() + + try { + const currentUser = client.currentUser() + if (currentUser) { + await currentUser.logout() + } + deleteBrowserAuthCookies() + stopTokenRefresh() + emitAuthEvent(AUTH_EVENTS.LOGOUT, null) + } catch (error) { + throw AuthError.from(error) + } +} + +/** + * Initiates an OAuth login by redirecting to the given provider (e.g., `'google'`, `'github'`). + * The page navigates away; this function never returns normally. Browser only. + * + * After the provider redirects back, call {@link handleAuthCallback} on page load + * to complete the login and obtain the {@link User}. + * + * @throws {AuthError} If called on the server. + */ +export const oauthLogin = (provider: AuthProvider): never => { + if (!isBrowser()) { + throw new AuthError('oauthLogin() is only available in the browser') + } + const client = getClient() + + window.location.href = client.loginExternalUrl(provider) + throw new AuthError('Redirecting to OAuth provider') +} + +/** + * Result returned by {@link handleAuthCallback} after processing a URL hash. + * + * - `'oauth'`: OAuth provider redirect completed. `user` is the authenticated user. + * - `'confirmation'`: Email confirmed via token. `user` is the confirmed user. + * - `'recovery'`: Password recovery token redeemed. `user` is logged in but must set a new password. + * - `'invite'`: Invite token found. `user` is `null`; `token` contains the invite token for {@link acceptInvite}. + * - `'email_change'`: Email change verified. `user` reflects the updated email. + * + * @example + * ```ts + * const result = await handleAuthCallback() + * if (result?.type === 'recovery') { + * redirect('/reset-password') + * } else if (result?.type === 'invite') { + * redirect(`/join?token=${result.token}`) + * } + * ``` + */ +export interface CallbackResult { + /** The type of auth callback that was processed. */ + type: 'oauth' | 'confirmation' | 'recovery' | 'invite' | 'email_change' + /** The authenticated user, or `null` for invite callbacks. */ + user: import('./user.js').User | null + /** The invite token, only present when `type` is `'invite'`. */ + token?: string +} + +/** + * Processes the URL hash after an OAuth redirect, email confirmation, password + * recovery, invite acceptance, or email change. Call on page load. Browser only. + * Returns `null` if the hash contains no auth parameters. + * + * Call this early in your app's initialization (e.g., in a layout component or + * root loader), **not** inside a route that requires authentication, because + * the callback URL must match the page where this function runs. + * + * For recovery callbacks (`result.type === 'recovery'`), the user is logged in + * but has **not** set a new password yet. Your app must check the result type + * and redirect to a password form that calls `updateUser({ password })`. + * A `'recovery'` event (not `'login'`) is emitted via {@link onAuthChange}. + * + * @throws {AuthError} If the callback token is invalid or the verification request fails. + */ +export const handleAuthCallback = async (): Promise => { + if (!isBrowser()) return null + + const hash = window.location.hash.substring(1) + if (!hash) return null + + const client = getClient() + const params = new URLSearchParams(hash) + + try { + const accessToken = params.get('access_token') + if (accessToken) return await handleOAuthCallback(client, params, accessToken) + + const confirmationToken = params.get('confirmation_token') + if (confirmationToken) return await handleConfirmationCallback(client, confirmationToken) + + const recoveryToken = params.get('recovery_token') + if (recoveryToken) return await handleRecoveryCallback(client, recoveryToken) + + const inviteToken = params.get('invite_token') + if (inviteToken) return handleInviteCallback(inviteToken) + + const emailChangeToken = params.get('email_change_token') + if (emailChangeToken) return await handleEmailChangeCallback(client, emailChangeToken) + + return null + } catch (error) { + if (error instanceof AuthError) throw error + throw AuthError.from(error) + } +} + +const handleOAuthCallback = async ( + client: GoTrue, + params: URLSearchParams, + accessToken: string, +): Promise => { + const refreshToken = params.get('refresh_token') ?? '' + const expiresIn = parseInt(params.get('expires_in') ?? '', 10) + const expiresAt = parseInt(params.get('expires_at') ?? '', 10) + const gotrueUser = await client.createUser( + { + access_token: accessToken, + token_type: (params.get('token_type') ?? 'bearer') as 'bearer', + expires_in: isFinite(expiresIn) ? expiresIn : 3600, + expires_at: isFinite(expiresAt) ? expiresAt : Math.floor(Date.now() / 1000) + 3600, + refresh_token: refreshToken, + }, + persistSession, + ) + setBrowserAuthCookies(accessToken, refreshToken || undefined) + const user = toUser(gotrueUser) + startTokenRefresh() + clearHash() + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + return { type: 'oauth', user } +} + +const handleConfirmationCallback = async (client: GoTrue, token: string): Promise => { + const gotrueUser = await client.confirm(token, persistSession) + const jwt = await gotrueUser.jwt() + setBrowserAuthCookies(jwt, gotrueUser.tokenDetails()?.refresh_token) + const user = toUser(gotrueUser) + startTokenRefresh() + clearHash() + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + return { type: 'confirmation', user } +} + +const handleRecoveryCallback = async (client: GoTrue, token: string): Promise => { + const gotrueUser = await client.recover(token, persistSession) + const jwt = await gotrueUser.jwt() + setBrowserAuthCookies(jwt, gotrueUser.tokenDetails()?.refresh_token) + const user = toUser(gotrueUser) + startTokenRefresh() + clearHash() + emitAuthEvent(AUTH_EVENTS.RECOVERY, user) + return { type: 'recovery', user } +} + +const handleInviteCallback = (token: string): CallbackResult => { + clearHash() + return { type: 'invite', user: null, token } +} + +const handleEmailChangeCallback = async (client: GoTrue, emailChangeToken: string): Promise => { + const currentUser = client.currentUser() + if (!currentUser) { + throw new AuthError('Email change verification requires an active browser session') + } + + const jwt = (await currentUser.jwt()) as string + const identityUrl = `${window.location.origin}${IDENTITY_PATH}` + + const emailChangeRes = await fetch(`${identityUrl}/user`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ email_change_token: emailChangeToken }), + }) + + if (!emailChangeRes.ok) { + const errorBody = (await emailChangeRes.json().catch(() => ({}))) as GoTrueErrorBody + throw new AuthError( + errorBody.msg ?? `Email change verification failed (${String(emailChangeRes.status)})`, + emailChangeRes.status, + ) + } + + const emailChangeData = (await emailChangeRes.json()) as UserData + const user = toUser(emailChangeData) + clearHash() + emitAuthEvent(AUTH_EVENTS.USER_UPDATED, user) + return { type: 'email_change', user } +} + +const clearHash = (): void => { + history.replaceState(null, '', window.location.pathname + window.location.search) +} + +/** + * Hydrates the browser-side session from server-set auth cookies. + * Call this on page load when using server-side login to enable browser + * account operations (updateUser, verifyEmailChange, etc.). + * + * No-op if a browser session already exists or no auth cookies are present. + * No-op on the server. + */ +export const hydrateSession = async (): Promise => { + if (!isBrowser()) return null + + const client = getClient() + const currentUser = client.currentUser() + if (currentUser) { + startTokenRefresh() + return toUser(currentUser) + } + + const accessToken = getCookie(NF_JWT_COOKIE) + if (!accessToken) return null + + const refreshToken = getCookie(NF_REFRESH_COOKIE) ?? '' + + const decoded = decodeJwtPayload(accessToken) + const expiresAt = decoded?.exp ?? Math.floor(Date.now() / 1000) + 3600 + const expiresIn = Math.max(0, expiresAt - Math.floor(Date.now() / 1000)) + + let gotrueUser + try { + gotrueUser = await client.createUser( + { + access_token: accessToken, + token_type: 'bearer', + expires_in: expiresIn, + expires_at: expiresAt, + refresh_token: refreshToken, + }, + persistSession, + ) + } catch { + deleteBrowserAuthCookies() + return null + } + + const user = toUser(gotrueUser) + startTokenRefresh() + emitAuthEvent(AUTH_EVENTS.LOGIN, user) + return user +} diff --git a/packages/identity/prod/src/config.ts b/packages/identity/prod/src/config.ts new file mode 100644 index 00000000..e4c5a999 --- /dev/null +++ b/packages/identity/prod/src/config.ts @@ -0,0 +1,46 @@ +import type { AuthProvider, IdentityConfig, Settings } from './types.js' +import { getClient, getIdentityContext, IDENTITY_PATH, isBrowser } from './environment.js' +import { AuthError } from './errors.js' + +/** + * Returns the identity configuration for the current environment. + * Browser: always returns `{ url }` derived from `window.location.origin`. + * Server: returns `{ url, token }` from the identity context, or `null` if unavailable. + * Never throws. + */ +export const getIdentityConfig = (): IdentityConfig | null => { + if (isBrowser()) { + return { url: `${window.location.origin}${IDENTITY_PATH}` } + } + + return getIdentityContext() +} + +/** + * Fetches your project's Identity settings (enabled providers, autoconfirm, signup disabled). + * + * @throws {MissingIdentityError} If Identity is not configured. + * @throws {AuthError} If the endpoint is unreachable. + */ +export const getSettings = async (): Promise => { + const client = getClient() + + try { + const raw = await client.settings() + const external: Partial> = raw.external ?? {} + return { + autoconfirm: raw.autoconfirm, + disableSignup: raw.disable_signup, + providers: { + google: external.google ?? false, + github: external.github ?? false, + gitlab: external.gitlab ?? false, + bitbucket: external.bitbucket ?? false, + facebook: external.facebook ?? false, + email: external.email ?? false, + }, + } + } catch (err) { + throw new AuthError(err instanceof Error ? err.message : 'Failed to fetch identity settings', 502, { cause: err }) + } +} diff --git a/packages/identity/prod/src/cookies.ts b/packages/identity/prod/src/cookies.ts new file mode 100644 index 00000000..e11c0893 --- /dev/null +++ b/packages/identity/prod/src/cookies.ts @@ -0,0 +1,70 @@ +import type { NetlifyCookies } from './types.js' + +export const NF_JWT_COOKIE = 'nf_jwt' +export const NF_REFRESH_COOKIE = 'nf_refresh' + +/** Reads a cookie value from `document.cookie` by name. Returns `null` if not found or not in a browser. */ +export const getCookie = (name: string): string | null => { + if (typeof document === 'undefined') return null + const match = new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`).exec(document.cookie) + if (!match) return null + try { + return decodeURIComponent(match[1]) + } catch { + return match[1] + } +} + +/** Sets the `nf_jwt` and (optionally) `nf_refresh` auth cookies via the Netlify runtime. */ +export const setAuthCookies = (cookies: NetlifyCookies, accessToken: string, refreshToken?: string): void => { + cookies.set({ + name: NF_JWT_COOKIE, + value: accessToken, + httpOnly: false, + secure: true, + path: '/', + sameSite: 'Lax', + }) + + if (refreshToken) { + // httpOnly: false because browser-side hydration (backgroundHydrate, hydrateSession) + // reads nf_refresh via document.cookie to bootstrap the gotrue-js session. + cookies.set({ + name: NF_REFRESH_COOKIE, + value: refreshToken, + httpOnly: false, + secure: true, + path: '/', + sameSite: 'Lax', + }) + } +} + +/** Deletes both auth cookies via the Netlify runtime. */ +export const deleteAuthCookies = (cookies: NetlifyCookies): void => { + cookies.delete(NF_JWT_COOKIE) + cookies.delete(NF_REFRESH_COOKIE) +} + +/** Sets auth cookies via document.cookie (browser-side). No-op on the server. */ +export const setBrowserAuthCookies = (accessToken: string, refreshToken?: string): void => { + if (typeof document === 'undefined') return + document.cookie = `${NF_JWT_COOKIE}=${encodeURIComponent(accessToken)}; path=/; secure; samesite=lax` + if (refreshToken) { + document.cookie = `${NF_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}; path=/; secure; samesite=lax` + } +} + +/** Deletes auth cookies via document.cookie (browser-side). No-op on the server. */ +export const deleteBrowserAuthCookies = (): void => { + if (typeof document === 'undefined') return + document.cookie = `${NF_JWT_COOKIE}=; path=/; secure; samesite=lax; expires=Thu, 01 Jan 1970 00:00:00 GMT` + document.cookie = `${NF_REFRESH_COOKIE}=; path=/; secure; samesite=lax; expires=Thu, 01 Jan 1970 00:00:00 GMT` +} + +/** Reads a cookie from the server-side Netlify runtime. Returns `null` if not available. */ +export const getServerCookie = (name: string): string | null => { + const cookies = globalThis.Netlify?.context?.cookies + if (!cookies || typeof cookies.get !== 'function') return null + return cookies.get(name) ?? null +} diff --git a/packages/identity/prod/src/csrf.ts b/packages/identity/prod/src/csrf.ts new file mode 100644 index 00000000..4207847b --- /dev/null +++ b/packages/identity/prod/src/csrf.ts @@ -0,0 +1,69 @@ +import { AuthError } from './errors.js' + +/** + * Options for {@link verifyRequestOrigin}. + */ +export interface VerifyRequestOriginOptions { + /** + * Origins that are allowed to make state-changing requests to this endpoint. + * + * If omitted, the request is only accepted from its own origin (the origin of `request.url`), + * which is the right default for sites whose login form and login endpoint live on the same origin. + * + * Pass an explicit list when you trust additional origins (for example, a separate frontend domain + * posting to an API on another domain). The list replaces the default, so it must include every + * origin you want to allow, including the request's own origin if applicable. + * + * Each value should be a full origin string with scheme and host: `'https://example.com'`. + */ + allowedOrigins?: string[] +} + +/** + * Same-origin check for state-changing requests, can be used to defend against Cross-Site Request + * Forgery (CSRF) on server-side endpoints that call {@link login}, {@link signup}, or {@link logout}. + * + * Compares the incoming request's `Origin` header against the request's own origin (or an explicit + * allowlist via `options.allowedOrigins`) and throws if they don't match. Call this at the start of + * any server-side handler that performs an auth mutation, before invoking the auth function. + * + * The check runs unconditionally on every call: any HTTP method, with or without an `Origin` header. + * If you don't want the check to apply to a given method or path, simply don't call the helper there. + * + * @throws {AuthError} with status `403` when the request has no `Origin` header. + * @throws {AuthError} with status `403` when the request's `Origin` is not in the allowed origins. + * + * @example + * ```ts + * // Netlify Function + * import { login, verifyRequestOrigin } from '@netlify/identity' + * import type { Context } from '@netlify/functions' + * + * export default async (req: Request, context: Context) => { + * verifyRequestOrigin(req) + * const { email, password } = await req.json() + * await login(email, password) + * return new Response(null, { status: 302, headers: { Location: '/dashboard' } }) + * } + * ``` + * + * @example + * ```ts + * // Allow a separate trusted origin (e.g. a marketing site posting to an app domain). + * // The list replaces the default, so include the request's own origin if you still want it allowed. + * verifyRequestOrigin(request, { + * allowedOrigins: ['https://app.example.com', 'https://www.example.com'], + * }) + * ``` + */ +export const verifyRequestOrigin = (request: Request, options?: VerifyRequestOriginOptions): void => { + const origin = request.headers.get('origin') + if (!origin) { + throw new AuthError('Cross-origin request refused: missing Origin header.', 403) + } + + const allowed = options?.allowedOrigins ?? [new URL(request.url).origin] + if (!allowed.includes(origin)) { + throw new AuthError(`Cross-origin request refused: Origin ${origin} did not match an allowed origin.`, 403) + } +} diff --git a/packages/identity/prod/src/environment.ts b/packages/identity/prod/src/environment.ts new file mode 100644 index 00000000..562ee2cf --- /dev/null +++ b/packages/identity/prod/src/environment.ts @@ -0,0 +1,102 @@ +import GoTrue from 'gotrue-js' + +import type { IdentityConfig } from './types.js' +import { MissingIdentityError } from './errors.js' + +export const IDENTITY_PATH = '/.netlify/identity' + +let goTrueClient: GoTrue | null = null +let cachedApiUrl: string | null | undefined +let warnedMissingUrl = false + +export const isBrowser = (): boolean => typeof window !== 'undefined' && typeof window.location !== 'undefined' + +/** + * Discovers and caches the Identity API URL. + * + * Browser: uses `window.location.origin` + IDENTITY_PATH. + * Server: reads from `globalThis.netlifyIdentityContext`. + */ +const discoverApiUrl = (): string | null => { + if (cachedApiUrl !== undefined) return cachedApiUrl + + if (isBrowser()) { + cachedApiUrl = `${window.location.origin}${IDENTITY_PATH}` + } else { + const identityContext = getIdentityContext() + if (identityContext?.url) { + cachedApiUrl = identityContext.url + } else if (globalThis.Netlify?.context?.url) { + cachedApiUrl = new URL(IDENTITY_PATH, globalThis.Netlify.context.url).href + } else if (typeof process !== 'undefined' && process.env?.URL) { + cachedApiUrl = new URL(IDENTITY_PATH, process.env.URL).href + } + } + + return cachedApiUrl ?? null +} + +/** + * Returns (and lazily creates) a singleton Identity client. + * Returns `null` and logs a warning if no identity URL can be discovered. + */ +export const getGoTrueClient = (): GoTrue | null => { + if (goTrueClient) return goTrueClient + + const apiUrl = discoverApiUrl() + if (!apiUrl) { + if (!warnedMissingUrl) { + console.warn( + '@netlify/identity: Could not determine the Identity endpoint URL. ' + + 'Make sure your site has Netlify Identity enabled, or run your app with `netlify dev`.', + ) + warnedMissingUrl = true + } + return null + } + + goTrueClient = new GoTrue({ APIUrl: apiUrl, setCookie: false }) + return goTrueClient +} + +/** + * Returns the singleton Identity client, or throws if Identity is not configured. + */ +export const getClient = (): GoTrue => { + const client = getGoTrueClient() + if (!client) throw new MissingIdentityError() + return client +} + +/** + * Reads the server-side identity context set by the Netlify bootstrap. + * Returns `null` outside the Netlify serverless environment. + */ +export const getIdentityContext = (): IdentityConfig | null => { + const identityContext = globalThis.netlifyIdentityContext + if (identityContext?.url) { + return { + url: identityContext.url, + token: identityContext.token, + } + } + + if (globalThis.Netlify?.context?.url) { + return { url: new URL(IDENTITY_PATH, globalThis.Netlify.context.url).href } + } + + // Fallback: Netlify sets the URL env var on all deployed sites + const siteUrl = typeof process !== 'undefined' ? process.env?.URL : undefined + if (siteUrl) { + return { url: new URL(IDENTITY_PATH, siteUrl).href } + } + + return null +} + +/** Reset cached state for tests. */ +export const resetTestGoTrueClient = (): void => { + goTrueClient = null + cachedApiUrl = undefined + warnedMissingUrl = false +} diff --git a/packages/identity/prod/src/errors.ts b/packages/identity/prod/src/errors.ts new file mode 100644 index 00000000..7743973b --- /dev/null +++ b/packages/identity/prod/src/errors.ts @@ -0,0 +1,54 @@ +/** + * Thrown by auth operations when something goes wrong: invalid credentials, + * network failures, missing runtime context, etc. + * + * The `status` field contains the HTTP status code from the Identity API when available + * (e.g., 401 for bad credentials, 422 for validation errors). + * The `cause` field preserves the original error for debugging. + * + * @example + * ```ts + * try { + * await login(email, password) + * } catch (error) { + * if (error instanceof AuthError) { + * console.error(error.message, error.status) + * } + * } + * ``` + */ +export class AuthError extends Error { + override name = 'AuthError' + /** HTTP status code from the Identity API, if the error originated from an API response. */ + status?: number + declare cause?: unknown + + constructor(message: string, status?: number, options?: { cause?: unknown }) { + super(message) + this.status = status + if (options && 'cause' in options) { + this.cause = options.cause + } + } + + static from(error: unknown): AuthError { + if (error instanceof AuthError) return error + const message = error instanceof Error ? error.message : String(error) + return new AuthError(message, undefined, { cause: error }) + } +} + +/** + * Thrown when a function requires the Identity client but Netlify Identity + * is not configured (no endpoint URL could be discovered). + * + * This typically means the site does not have Identity enabled, or the app + * is not running via `netlify dev` / deployed on Netlify. + */ +export class MissingIdentityError extends Error { + override name = 'MissingIdentityError' + + constructor(message = 'Netlify Identity is not available.') { + super(message) + } +} diff --git a/packages/identity/prod/src/events.ts b/packages/identity/prod/src/events.ts new file mode 100644 index 00000000..9ce362db --- /dev/null +++ b/packages/identity/prod/src/events.ts @@ -0,0 +1,89 @@ +import { getGoTrueClient, isBrowser } from './environment.js' +import { toUser, type User } from './user.js' + +/** + * Constants for the auth events emitted by the library. + * Use these instead of string literals when comparing event types. + * + * @example + * ```ts + * onAuthChange((event, user) => { + * if (event === AUTH_EVENTS.LOGIN) console.log('Logged in:', user) + * if (event === AUTH_EVENTS.RECOVERY) redirect('/reset-password') + * }) + * ``` + */ +export const AUTH_EVENTS = { + LOGIN: 'login', + LOGOUT: 'logout', + TOKEN_REFRESH: 'token_refresh', + USER_UPDATED: 'user_updated', + RECOVERY: 'recovery', +} as const + +/** + * Union of all auth event names: `'login' | 'logout' | 'token_refresh' | 'user_updated' | 'recovery'`. + * Passed as the first argument to {@link AuthCallback} subscribers. + */ +export type AuthEvent = (typeof AUTH_EVENTS)[keyof typeof AUTH_EVENTS] + +/** + * Callback function signature for {@link onAuthChange} subscribers. + * `user` is `null` on logout events. + */ +export type AuthCallback = (event: AuthEvent, user: User | null) => void + +const GOTRUE_STORAGE_KEY = 'gotrue.user' + +const listeners = new Set() + +export const emitAuthEvent = (event: AuthEvent, user: User | null): void => { + for (const listener of listeners) { + try { + listener(event, user) + } catch { + // Prevent one subscriber from breaking others + } + } +} + +let storageListenerAttached = false + +const attachStorageListener = (): void => { + if (storageListenerAttached || !isBrowser()) return + storageListenerAttached = true + + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key !== GOTRUE_STORAGE_KEY) return + + if (event.newValue) { + const client = getGoTrueClient() + const currentUser = client?.currentUser() + emitAuthEvent(AUTH_EVENTS.LOGIN, currentUser ? toUser(currentUser) : null) + } else { + emitAuthEvent(AUTH_EVENTS.LOGOUT, null) + } + }) +} + +/** + * Subscribes to auth state changes (login, logout, token refresh, user updates, + * and recovery). Returns an unsubscribe function. No-op on the server. + * + * The `'recovery'` event fires when {@link handleAuthCallback} processes a + * password recovery token. The user is logged in but has not yet set a new + * password. Redirect them to a password reset form and call + * `updateUser({ password })` to complete the flow. + */ +export const onAuthChange = (callback: AuthCallback): (() => void) => { + if (!isBrowser()) { + return () => {} + } + + listeners.add(callback) + attachStorageListener() + + return () => { + listeners.delete(callback) + } +} diff --git a/packages/identity/prod/src/fetch.ts b/packages/identity/prod/src/fetch.ts new file mode 100644 index 00000000..91c8feae --- /dev/null +++ b/packages/identity/prod/src/fetch.ts @@ -0,0 +1,35 @@ +import { AuthError } from './errors.js' + +/** Default timeout for server-side Identity API requests (ms). */ +const DEFAULT_TIMEOUT_MS = 5000 + +/** + * Wraps `fetch` with an AbortController timeout for server-side requests. + * If the request doesn't complete within `timeoutMs`, the connection is + * aborted and an AuthError is thrown. + * + * Not exported from the package; used internally by auth, admin, refresh, + * and user modules for all server-side Identity API calls. + */ +export const fetchWithTimeout = async ( + url: string, + options: RequestInit = {}, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise => { + const controller = new AbortController() + const timer = setTimeout(() => { + controller.abort() + }, timeoutMs) + + try { + return await fetch(url, { ...options, signal: controller.signal }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + const pathname = new URL(url).pathname + throw new AuthError(`Identity request to ${pathname} timed out after ${String(timeoutMs)}ms`) + } + throw error + } finally { + clearTimeout(timer) + } +} diff --git a/packages/identity/prod/src/globals.d.ts b/packages/identity/prod/src/globals.d.ts new file mode 100644 index 00000000..6a5e9e7c --- /dev/null +++ b/packages/identity/prod/src/globals.d.ts @@ -0,0 +1,15 @@ +import type { NetlifyCookies } from './types.js' +import type { IdentityUser } from './user.js' + +interface NetlifyIdentityContext { + url?: string + token?: string + user?: IdentityUser +} + +declare global { + var netlifyIdentityContext: NetlifyIdentityContext | undefined + var Netlify: { context?: { url?: string; cookies?: NetlifyCookies } } | undefined +} + +export {} diff --git a/packages/identity/prod/src/main.ts b/packages/identity/prod/src/main.ts new file mode 100644 index 00000000..6a87b8d6 --- /dev/null +++ b/packages/identity/prod/src/main.ts @@ -0,0 +1,32 @@ +export type { User } from './user.js' +export { getUser, isAuthenticated } from './user.js' +export { getIdentityConfig, getSettings } from './config.js' +export type { AuthCallback, AuthEvent } from './events.js' +export { AUTH_EVENTS, onAuthChange } from './events.js' +export type { CallbackResult } from './auth.js' +export { login, signup, logout, oauthLogin, handleAuthCallback, hydrateSession } from './auth.js' +export { refreshSession } from './refresh.js' +export { AuthError, MissingIdentityError } from './errors.js' +export { verifyRequestOrigin } from './csrf.js' +export type { VerifyRequestOriginOptions } from './csrf.js' +export type { + AdminUserUpdates, + AppMetadata, + AuthProvider, + CreateUserParams, + IdentityConfig, + ListUsersOptions, + Settings, + UserUpdates, + SignupData, +} from './types.js' +export type { Admin } from './admin.js' +export { + requestPasswordRecovery, + recoverPassword, + confirmEmail, + acceptInvite, + verifyEmailChange, + updateUser, +} from './account.js' +export { admin } from './admin.js' diff --git a/packages/identity/prod/src/nextjs.ts b/packages/identity/prod/src/nextjs.ts new file mode 100644 index 00000000..32c1f0bb --- /dev/null +++ b/packages/identity/prod/src/nextjs.ts @@ -0,0 +1,49 @@ +// Minimal declaration so we can use require() without @types/node +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare const require: ((id: string) => any) | undefined + +/** + * Calls `headers()` from `next/headers` if available, to opt Next.js RSC + * routes into dynamic rendering. Without this, Next.js may statically + * optimize pages that call functions in this package, caching the build-time result. + * + * Re-throws DynamicServerError so Next.js can catch it and switch to + * dynamic rendering. Silently ignores if not in a Next.js environment. + */ +let nextHeadersFn: (() => unknown) | null | undefined +export const triggerNextjsDynamic = (): void => { + if (nextHeadersFn === null) return + + if (nextHeadersFn === undefined) { + try { + if (typeof require === 'undefined') { + nextHeadersFn = null + return + } + const mod = require('next/headers') + nextHeadersFn = mod.headers + } catch { + nextHeadersFn = null + return + } + } + + const fn = nextHeadersFn + if (!fn) return + + try { + fn() + } catch (e: unknown) { + // Re-throw DynamicServerError so Next.js can opt into dynamic rendering. + // These errors have a `digest` property containing 'DYNAMIC_SERVER_USAGE' + // or a message about bailing out of prerendering. + if (e instanceof Error && ('digest' in e || /bail\s*out.*prerende/i.test(e.message))) { + throw e + } + } +} + +/** Reset cached state and optionally inject a headers function. Test use only. */ +export const resetNextjsState = (headersFn?: (() => unknown) | null): void => { + nextHeadersFn = headersFn === null ? null : (headersFn ?? undefined) +} diff --git a/packages/identity/prod/src/refresh.ts b/packages/identity/prod/src/refresh.ts new file mode 100644 index 00000000..28a51247 --- /dev/null +++ b/packages/identity/prod/src/refresh.ts @@ -0,0 +1,194 @@ +import { getGoTrueClient, isBrowser, getIdentityContext, IDENTITY_PATH } from './environment.js' +import { + NF_JWT_COOKIE, + NF_REFRESH_COOKIE, + setBrowserAuthCookies, + setAuthCookies, + deleteAuthCookies, + getServerCookie, +} from './cookies.js' +import { decodeJwtPayload, toUser } from './user.js' +import { AUTH_EVENTS, emitAuthEvent } from './events.js' +import { AuthError } from './errors.js' +import { fetchWithTimeout } from './fetch.js' +import type { NetlifyCookies, TokenResponse, GoTrueErrorBody } from './types.js' + +/** Seconds before expiry to trigger a refresh. */ +const REFRESH_MARGIN_S = 60 + +let refreshTimer: ReturnType | null = null + +/** + * Starts a browser-side timer that refreshes the access token before it expires + * and syncs the new token back to the `nf_jwt` cookie. Automatically called by + * any browser flow that establishes a session (`login`, `signup`, + * `hydrateSession`, `handleAuthCallback`, `confirmEmail`, `recoverPassword`, + * `acceptInvite`) and by `getUser` when it finds an existing session. + * No-op on the server. + * + * Safe to call multiple times; restarts the timer with the current token's expiry. + */ +export const startTokenRefresh = (): void => { + if (!isBrowser()) return + stopTokenRefresh() + + const client = getGoTrueClient() + const user = client?.currentUser() + if (!user) return + + const token = user.tokenDetails() + if (!token?.expires_at) return + + const nowS = Math.floor(Date.now() / 1000) + const expiresAtS = + typeof token.expires_at === 'number' && token.expires_at > 1e12 + ? Math.floor(token.expires_at / 1000) // gotrue-js stores expires_at in ms + : token.expires_at + const delayMs = Math.max(0, expiresAtS - nowS - REFRESH_MARGIN_S) * 1000 + + refreshTimer = setTimeout(() => { + void (async () => { + try { + const freshJwt = await user.jwt(true) + const freshDetails = user.tokenDetails() + setBrowserAuthCookies(freshJwt, freshDetails?.refresh_token) + emitAuthEvent(AUTH_EVENTS.TOKEN_REFRESH, toUser(user)) + // Schedule next refresh + startTokenRefresh() + } catch { + // Refresh failed (e.g., refresh token revoked). Stop trying. + stopTokenRefresh() + } + })() + }, delayMs) +} + +/** + * Stops the browser-side auto-refresh timer. Automatically called by `logout`. + */ +export const stopTokenRefresh = (): void => { + if (refreshTimer !== null) { + clearTimeout(refreshTimer) + refreshTimer = null + } +} + +/** + * Refreshes the session's access token. + * + * **Browser:** Token refresh is handled automatically after any browser flow + * that establishes a session (`login`, `signup`, `hydrateSession`, + * `handleAuthCallback`, `confirmEmail`, `recoverPassword`, `acceptInvite`) + * and by `getUser` when it finds an existing session. Calling + * `refreshSession()` in the browser triggers an + * immediate refresh if the token is near expiry. Returns the new JWT on + * success, or `null` if no refresh is needed. Browser-side errors (e.g., + * revoked refresh token) do not throw; they return `null`. + * + * **Server:** Reads the `nf_jwt` and `nf_refresh` cookies, checks if the token + * is expired or near expiry, and exchanges the refresh token for a new access + * token via the Identity `/token` endpoint. Updates both cookies on the response. + * Call this in framework middleware or at the start of server-side request + * handlers to ensure subsequent requests carry a valid JWT. + * + * Returns the new access token on success, or `null` if no refresh is needed + * or the refresh token is invalid/missing (400/401). + * + * @throws {AuthError} Server-side only: on network failure or when the Identity URL cannot be determined. + * + * @example + * ```ts + * // In server middleware (e.g., Astro, SvelteKit) + * import { refreshSession } from '@netlify/identity' + * await refreshSession() + * ``` + */ +export const refreshSession = async (): Promise => { + if (isBrowser()) { + const client = getGoTrueClient() + const user = client?.currentUser() + if (!user) return null + + // Check if the token is near expiry before refreshing + const details = user.tokenDetails() + if (details?.expires_at) { + const nowS = Math.floor(Date.now() / 1000) + const expiresAtS = + typeof details.expires_at === 'number' && details.expires_at > 1e12 + ? Math.floor(details.expires_at / 1000) + : details.expires_at + if (expiresAtS - nowS > REFRESH_MARGIN_S) { + return null + } + } + + try { + const jwt = await user.jwt(true) + setBrowserAuthCookies(jwt, user.tokenDetails()?.refresh_token) + emitAuthEvent(AUTH_EVENTS.TOKEN_REFRESH, toUser(user)) + startTokenRefresh() + return jwt + } catch { + stopTokenRefresh() + return null + } + } + + // Server-side: read cookies, check expiry, refresh if needed + const accessToken = getServerCookie(NF_JWT_COOKIE) + const refreshToken = getServerCookie(NF_REFRESH_COOKIE) + + if (!accessToken || !refreshToken) return null + + const decoded = decodeJwtPayload(accessToken) + if (!decoded?.exp) return null + + const nowS = Math.floor(Date.now() / 1000) + if (decoded.exp - nowS > REFRESH_MARGIN_S) { + // Token is still valid, no refresh needed + return null + } + + // Token is expired or near expiry; exchange refresh token for new access token + const ctx = getIdentityContext() + const identityUrl = + ctx?.url ?? (globalThis.Netlify?.context?.url ? new URL(IDENTITY_PATH, globalThis.Netlify.context.url).href : null) + + if (!identityUrl) { + throw new AuthError('Could not determine the Identity endpoint URL for token refresh') + } + + let res: Response + try { + res = await fetchWithTimeout(`${identityUrl}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }).toString(), + }) + } catch (error) { + throw AuthError.from(error) + } + + if (!res.ok) { + // Refresh token is invalid/expired; cannot refresh + const errorBody = (await res.json().catch(() => ({}))) as GoTrueErrorBody + if (res.status === 401 || res.status === 400) { + // Invalid refresh token; clear stale cookies so middleware stops retrying + const cookies = globalThis.Netlify?.context?.cookies as NetlifyCookies | undefined + if (cookies) { + deleteAuthCookies(cookies) + } + return null + } + throw new AuthError(errorBody.msg ?? `Token refresh failed (${String(res.status)})`, res.status) + } + + const data = (await res.json()) as TokenResponse + + const cookies = globalThis.Netlify?.context?.cookies as NetlifyCookies | undefined + if (cookies) { + setAuthCookies(cookies, data.access_token, data.refresh_token) + } + + return data.access_token +} diff --git a/packages/identity/prod/src/types.ts b/packages/identity/prod/src/types.ts new file mode 100644 index 00000000..94de0367 --- /dev/null +++ b/packages/identity/prod/src/types.ts @@ -0,0 +1,167 @@ +/** The supported OAuth and authentication providers. */ +export const AUTH_PROVIDERS = ['google', 'github', 'gitlab', 'bitbucket', 'facebook', 'email'] as const + +/** A supported authentication provider name (e.g., `'google'`, `'github'`, `'email'`). */ +export type AuthProvider = (typeof AUTH_PROVIDERS)[number] + +/** + * Provider and role metadata stored in a user's `app_metadata` field. + * The `provider` field is set automatically on signup; `roles` controls authorization. + * Additional keys may be present depending on your Identity configuration. + * + * @example + * ```ts + * const meta: AppMetadata = { + * provider: 'github', + * roles: ['admin'], + * custom_claim: 'value', + * } + * ``` + */ +export interface AppMetadata { + provider: AuthProvider + roles?: string[] + [key: string]: unknown +} + +/** + * Identity endpoint configuration for the current environment. + * In the browser, `url` is derived from `window.location.origin`. + * On the server, `token` is the operator token for admin operations. + */ +export interface IdentityConfig { + /** The Identity API endpoint URL (e.g., `https://example.com/.netlify/identity`). */ + url: string + /** Operator token for server-side admin requests. Only available in Netlify Functions. */ + token?: string +} + +/** + * Project-level Identity settings returned by {@link getSettings}. + * Reflects the configuration in your Netlify dashboard. + */ +export interface Settings { + /** Whether new signups are auto-confirmed (no confirmation email sent). */ + autoconfirm: boolean + /** Whether new signups are disabled entirely. */ + disableSignup: boolean + /** Map of provider names to whether they are enabled. */ + providers: Record +} + +/** + * Fields accepted by {@link updateUser}. All fields are optional. + * Pass `data` to update user metadata (e.g., `{ data: { full_name: 'New Name' } }`). + * + * @example + * ```ts + * await updateUser({ data: { full_name: 'Jane Doe' } }) + * await updateUser({ email: 'new@example.com' }) + * await updateUser({ password: 'new-password' }) + * ``` + */ +export interface UserUpdates { + email?: string + password?: string + data?: Record + [key: string]: unknown +} + +/** + * User metadata passed during signup (e.g., `{ full_name: 'Jane Doe' }`). + * Stored in the user's `user_metadata` field. + */ +export type SignupData = Record + +/** OAuth2 token response from the Identity /token endpoint. */ +export interface TokenResponse { + access_token: string + token_type: string + expires_in: number + refresh_token?: string +} + +/** + * Fields accepted by {@link admin.updateUser}. All fields are optional. + * + * Unlike {@link UserUpdates} (used by the self-service `updateUser`), admin updates + * can set `role`, force-confirm a user, and write to `app_metadata`. + * + * @example + * ```ts + * await admin.updateUser(userId, { + * role: 'editor', + * confirm: true, + * app_metadata: { plan: 'pro' }, + * }) + * ``` + */ +export interface AdminUserUpdates { + email?: string + password?: string + /** The user's role (e.g., `'admin'`, `'editor'`). */ + role?: string + /** Set to `true` to force-confirm the user's email without sending a confirmation email. */ + confirm?: boolean + /** Server-managed metadata. Only writable via admin operations. */ + app_metadata?: Record + /** User-managed metadata (display name, avatar, preferences, etc.). */ + user_metadata?: Record +} + +/** Identity API error response body. */ +export interface GoTrueErrorBody { + msg?: string + error_description?: string +} + +/** + * Pagination options for {@link admin.listUsers}. + */ +export interface ListUsersOptions { + /** 1-based page number. */ + page?: number + /** Number of users per page. */ + perPage?: number +} + +/** + * Parameters for {@link admin.createUser}. + * + * The optional `data` fields are forwarded as top-level attributes in the Identity API + * request body. Only these keys are accepted: `role`, `app_metadata`, + * `user_metadata`. Any other keys are silently ignored. `data` cannot override + * `email`, `password`, or `confirm`. + * + * @example + * ```ts + * await admin.createUser({ + * email: 'jane@example.com', + * password: 'secret', + * data: { role: 'editor', user_metadata: { full_name: 'Jane Doe' } }, + * }) + * ``` + */ +export interface CreateUserParams { + email: string + password: string + /** Identity user fields: `role`, `app_metadata`, `user_metadata`. Other keys are ignored. */ + data?: Record +} + +/** + * Cookie interface provided by the Netlify Functions runtime. + * Used internally for server-side auth cookie management. + */ +export interface NetlifyCookies { + get(name: string): string | undefined + set(options: { + name: string + value: string + httpOnly: boolean + secure: boolean + path: string + sameSite: 'Strict' | 'Lax' | 'None' + }): void + delete(name: string): void +} diff --git a/packages/identity/prod/src/user.ts b/packages/identity/prod/src/user.ts new file mode 100644 index 00000000..167b5885 --- /dev/null +++ b/packages/identity/prod/src/user.ts @@ -0,0 +1,274 @@ +import type { UserData } from 'gotrue-js' +import { AUTH_PROVIDERS, type AuthProvider } from './types.js' +import { getGoTrueClient, getIdentityContext, isBrowser, IDENTITY_PATH } from './environment.js' +import { getCookie, getServerCookie, NF_JWT_COOKIE } from './cookies.js' +import { triggerNextjsDynamic } from './nextjs.js' +import { fetchWithTimeout } from './fetch.js' +import { hydrateSession } from './auth.js' +import { startTokenRefresh } from './refresh.js' + +/** Decoded JWT claims from the Identity token. Used internally to construct {@link User}. */ +export interface IdentityUser { + sub?: string + email?: string + exp?: number + app_metadata?: Record + user_metadata?: Record + [key: string]: unknown +} + +const toAuthProvider = (value: unknown): AuthProvider | undefined => + typeof value === 'string' && (AUTH_PROVIDERS as readonly string[]).includes(value) + ? (value as AuthProvider) + : undefined + +const toOptionalString = (value: unknown): string | undefined => + typeof value === 'string' && value !== '' ? value : undefined + +const toRoles = (appMeta: Record): string[] | undefined => { + const roles = appMeta.roles + if (Array.isArray(roles) && roles.every((r) => typeof r === 'string')) { + return roles + } + return undefined +} + +/** + * A normalized user object returned by all auth and admin functions. + * Provides a consistent shape regardless of whether the user was loaded + * from the Identity API, a JWT cookie, or the server-side identity context. + * + * All fields except `id` are optional and may be `undefined`. Empty strings + * are normalized to `undefined`. State-dependent fields (invite, + * recovery, email-change) are only present when the user is in that state. + * + * @example + * ```ts + * const user = await getUser() + * if (user) { + * console.log(user.email, user.name, user.roles) + * } + * ``` + */ +export interface User { + /** The user's unique identifier. */ + id: string + /** The user's email address. */ + email?: string + /** ISO 8601 timestamp of when the user's email was confirmed. `undefined` if not yet confirmed. */ + confirmedAt?: string + /** ISO 8601 timestamp of when the account was created. */ + createdAt?: string + /** ISO 8601 timestamp of the last account update. */ + updatedAt?: string + /** + * The account-level role string (e.g., `"admin"`). This is a single value + * set via the admin API, distinct from `roles` which is an array in `app_metadata`. + * `undefined` when not set or empty. + */ + role?: string + /** The authentication provider used to create the account (from `app_metadata.provider`). */ + provider?: AuthProvider + /** Display name from `user_metadata.full_name` or `user_metadata.name`. */ + name?: string + /** Avatar URL from `user_metadata.avatar_url`. */ + pictureUrl?: string + /** Application-level roles from `app_metadata.roles`, set via the admin API or Netlify UI. */ + roles?: string[] + /** ISO 8601 timestamp of when the user was invited. Only present if the user was created via invitation. */ + invitedAt?: string + /** ISO 8601 timestamp of when the confirmation email was last sent. */ + confirmationSentAt?: string + /** ISO 8601 timestamp of when the recovery email was last sent. */ + recoverySentAt?: string + /** The pending email address during an email change flow. Only present while the change is awaiting confirmation. */ + pendingEmail?: string + /** ISO 8601 timestamp of when the email change verification was last sent. */ + emailChangeSentAt?: string + /** ISO 8601 timestamp of the user's most recent sign-in. */ + lastSignInAt?: string + /** Custom user metadata. Contains profile data like `full_name` and `avatar_url`, and any custom fields set via `updateUser()`. */ + userMetadata?: Record + /** Application metadata managed by the server. Contains `provider`, `roles`, and other system-managed fields. */ + appMetadata?: Record +} + +export const toUser = (userData: UserData): User => { + const userMeta = userData.user_metadata ?? {} + const appMeta = userData.app_metadata ?? {} + const name = userMeta.full_name ?? userMeta.name + const pictureUrl = userMeta.avatar_url + + return { + id: userData.id, + email: userData.email, + confirmedAt: toOptionalString(userData.confirmed_at), + createdAt: userData.created_at, + updatedAt: userData.updated_at, + role: toOptionalString(userData.role), + provider: toAuthProvider(appMeta.provider), + name: typeof name === 'string' ? name : undefined, + pictureUrl: typeof pictureUrl === 'string' ? pictureUrl : undefined, + roles: toRoles(appMeta), + invitedAt: toOptionalString(userData.invited_at), + confirmationSentAt: toOptionalString(userData.confirmation_sent_at), + recoverySentAt: toOptionalString(userData.recovery_sent_at), + pendingEmail: toOptionalString(userData.new_email), + emailChangeSentAt: toOptionalString(userData.email_change_sent_at), + lastSignInAt: toOptionalString(userData.last_sign_in_at), + userMetadata: userMeta, + appMetadata: appMeta, + } +} + +/** + * Converts JWT claims into a User. Used as a fallback when the full user + * object is unavailable (e.g., Identity API is unreachable on the server). + * + * JWT claims only contain `sub`, `email`, `exp`, `app_metadata`, and + * `user_metadata`. All other User fields (timestamps, aud, role, etc.) + * will be `undefined`. + */ +const claimsToUser = (claims: IdentityUser): User => { + const appMeta = claims.app_metadata ?? {} + const userMeta = claims.user_metadata ?? {} + const name = userMeta.full_name ?? userMeta.name + const pictureUrl = userMeta.avatar_url + + return { + id: claims.sub ?? '', + email: claims.email, + provider: toAuthProvider(appMeta.provider), + name: typeof name === 'string' ? name : undefined, + pictureUrl: typeof pictureUrl === 'string' ? pictureUrl : undefined, + roles: toRoles(appMeta), + userMetadata: userMeta, + appMetadata: appMeta, + } +} + +/** Decodes a JWT payload without verifying the signature. */ +export const decodeJwtPayload = (token: string): IdentityUser | null => { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')) + return JSON.parse(payload) as IdentityUser + } catch { + return null + } +} + +/** + * Fetches the full user object from the Identity API using the JWT. + * Returns null if the fetch fails (API unreachable, invalid token, etc.). + */ +const fetchFullUser = async (identityUrl: string, jwt: string): Promise => { + try { + const res = await fetchWithTimeout(`${identityUrl}/user`, { + headers: { Authorization: `Bearer ${jwt}` }, + }) + if (!res.ok) return null + const userData = (await res.json()) as UserData + return toUser(userData) + } catch { + return null + } +} + +/** + * Resolves the Identity URL from available sources, or null if not discoverable. + */ +const resolveIdentityUrl = (): string | null => { + const identityContext = getIdentityContext() + if (identityContext?.url) return identityContext.url + + if (globalThis.Netlify?.context?.url) { + return new URL(IDENTITY_PATH, globalThis.Netlify.context.url).href + } + + const siteUrl = typeof process !== 'undefined' ? process.env?.URL : undefined + if (siteUrl) { + return new URL(IDENTITY_PATH, siteUrl).href + } + + return null +} + +/** + * Returns the currently authenticated user, or `null` if not logged in. + * Never throws; returns `null` on any failure. + * + * Always returns a full {@link User} object with all available fields + * (email, roles, timestamps, metadata, etc.) regardless of whether the + * call happens in the browser or on the server. + * + * In the browser, checks localStorage first. If no localStorage + * session exists, hydrates from the `nf_jwt` cookie (set by server-side login). + * + * On the server, fetches the full user from the Identity API using the JWT from + * the request. Falls back to JWT claims if the Identity API is unreachable. + * + * On the server in a Next.js App Router context, calls `headers()` from + * `next/headers` to opt the route into dynamic rendering. Without this, + * Next.js may statically cache the page at build time. + */ +export const getUser = async (): Promise => { + if (isBrowser()) { + const client = getGoTrueClient() + const currentUser = client?.currentUser() ?? null + + if (currentUser) { + // If gotrue-js has a localStorage session but the nf_jwt cookie is gone, + // the server logged us out. Clear the stale localStorage session. + const jwt = getCookie(NF_JWT_COOKIE) + if (!jwt) { + try { + currentUser.clearSession() + } catch { + // best-effort cleanup + } + return null + } + startTokenRefresh() + return toUser(currentUser) + } + + // No gotrue-js session but cookie exists: hydrate to get the full user + const jwt = getCookie(NF_JWT_COOKIE) + if (!jwt) return null + + // Verify the cookie contains a decodable JWT before attempting hydration + const claims = decodeJwtPayload(jwt) + if (!claims) return null + + const hydrated = await hydrateSession() + return hydrated ?? null + } + + // Trigger Next.js dynamic rendering if in a Next.js RSC context + triggerNextjsDynamic() + + // Get the JWT from the identity context header or cookie + const identityContext = globalThis.netlifyIdentityContext + const serverJwt = identityContext?.token ?? getServerCookie(NF_JWT_COOKIE) + + // Try to fetch the full user from GoTrue for a complete User object + if (serverJwt) { + const identityUrl = resolveIdentityUrl() + if (identityUrl) { + const fullUser = await fetchFullUser(identityUrl, serverJwt) + if (fullUser) return fullUser + } + } + + // Fallback: only use server-validated identity context, never decode an unverified cookie + const claims = identityContext?.user ?? null + return claims ? claimsToUser(claims) : null +} + +/** + * Returns `true` if a user is currently authenticated. + * Never throws; returns `false` on any failure. + */ +export const isAuthenticated = async (): Promise => (await getUser()) !== null diff --git a/packages/identity/prod/test/account.browser.test.ts b/packages/identity/prod/test/account.browser.test.ts new file mode 100644 index 00000000..b0fc67c2 --- /dev/null +++ b/packages/identity/prod/test/account.browser.test.ts @@ -0,0 +1,263 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://localhost" } + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' +import { makeGoTrueUser, clearBrowserAuthCookies } from './fixtures.js' + +const mockRequestPasswordRecovery = vi.fn() +const mockRecover = vi.fn() +const mockConfirm = vi.fn() +const mockAcceptInvite = vi.fn() +const mockCurrentUser = vi.fn() +const mockUpdate = vi.fn() +const mockJwt = vi.fn() +const mockCreateUser = vi.fn() + +vi.mock('gotrue-js', () => ({ + default: class MockGoTrue { + requestPasswordRecovery = mockRequestPasswordRecovery + recover = mockRecover + confirm = mockConfirm + acceptInvite = mockAcceptInvite + currentUser = mockCurrentUser + createUser = mockCreateUser + }, +})) + +const gotrueUserWithUpdate = (overrides = {}) => { + const user = makeGoTrueUser(overrides) + return { ...user, update: mockUpdate } +} + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() + clearBrowserAuthCookies() +}) + +describe('requestPasswordRecovery', () => { + it('calls client.requestPasswordRecovery', async () => { + const { requestPasswordRecovery } = await import('../src/account.js') + mockRequestPasswordRecovery.mockResolvedValue(undefined) + + await requestPasswordRecovery('jane@example.com') + + expect(mockRequestPasswordRecovery).toHaveBeenCalledWith('jane@example.com') + }) +}) + +describe('recoverPassword', () => { + it('recovers the token then updates the password', async () => { + const { recoverPassword } = await import('../src/account.js') + const recoveredUser = gotrueUserWithUpdate() + mockRecover.mockResolvedValue(recoveredUser) + mockUpdate.mockResolvedValue(makeGoTrueUser()) + + const user = await recoverPassword('recovery-token', 'new-password') + + expect(mockRecover).toHaveBeenCalledWith('recovery-token', true) + expect(mockUpdate).toHaveBeenCalledWith({ password: 'new-password' }) + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('emits a login event', async () => { + const { recoverPassword } = await import('../src/account.js') + const { onAuthChange } = await import('../src/events.js') + mockRecover.mockResolvedValue(gotrueUserWithUpdate()) + mockUpdate.mockResolvedValue(makeGoTrueUser()) + + const cb = vi.fn() + onAuthChange(cb) + await recoverPassword('recovery-token', 'new-password') + + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) +}) + +describe('confirmEmail', () => { + it('confirms the token and returns a user', async () => { + const { confirmEmail } = await import('../src/account.js') + mockConfirm.mockResolvedValue(makeGoTrueUser()) + + const user = await confirmEmail('confirm-token') + + expect(mockConfirm).toHaveBeenCalledWith('confirm-token', true) + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('emits a login event', async () => { + const { confirmEmail } = await import('../src/account.js') + const { onAuthChange } = await import('../src/events.js') + mockConfirm.mockResolvedValue(makeGoTrueUser()) + + const cb = vi.fn() + onAuthChange(cb) + await confirmEmail('confirm-token') + + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) +}) + +describe('acceptInvite', () => { + it('accepts the invite with a password', async () => { + const { acceptInvite } = await import('../src/account.js') + mockAcceptInvite.mockResolvedValue(makeGoTrueUser()) + + const user = await acceptInvite('invite-token', 'my-password') + + expect(mockAcceptInvite).toHaveBeenCalledWith('invite-token', 'my-password', true) + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('emits a login event', async () => { + const { acceptInvite } = await import('../src/account.js') + const { onAuthChange } = await import('../src/events.js') + mockAcceptInvite.mockResolvedValue(makeGoTrueUser()) + + const cb = vi.fn() + onAuthChange(cb) + await acceptInvite('invite-token', 'my-password') + + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) +}) + +describe('verifyEmailChange', () => { + it('calls PUT /user with email_change_token', async () => { + const { verifyEmailChange } = await import('../src/account.js') + const userData = makeGoTrueUser() + mockCurrentUser.mockReturnValue({ jwt: mockJwt }) + mockJwt.mockResolvedValue('test-jwt-token') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(userData), + }), + ) + + const user = await verifyEmailChange('change-token') + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/.netlify/identity/user'), + expect.objectContaining({ + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + body: JSON.stringify({ email_change_token: 'change-token' }), + }), + ) + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('emits a user_updated event', async () => { + const { verifyEmailChange } = await import('../src/account.js') + const { onAuthChange } = await import('../src/events.js') + mockCurrentUser.mockReturnValue({ jwt: mockJwt }) + mockJwt.mockResolvedValue('test-jwt-token') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeGoTrueUser()), + }), + ) + + const cb = vi.fn() + onAuthChange(cb) + await verifyEmailChange('change-token') + + expect(cb).toHaveBeenCalledWith( + 'user_updated', + expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + ) + }) + + it('throws AuthError when no user is logged in', async () => { + const { verifyEmailChange } = await import('../src/account.js') + const { AuthError } = await import('../src/errors.js') + mockCurrentUser.mockReturnValue(null) + + const error = await verifyEmailChange('change-token').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('No user is currently logged in') + }) +}) + +describe('updateUser', () => { + it('updates the current user', async () => { + const { updateUser } = await import('../src/account.js') + mockCurrentUser.mockReturnValue(gotrueUserWithUpdate()) + mockUpdate.mockResolvedValue(makeGoTrueUser({ user_metadata: { full_name: 'New Name' } })) + + const user = await updateUser({ data: { full_name: 'New Name' } }) + + expect(mockUpdate).toHaveBeenCalledWith({ data: { full_name: 'New Name' } }) + expect(user.name).toBe('New Name') + }) + + it('emits a user_updated event', async () => { + const { updateUser } = await import('../src/account.js') + const { onAuthChange } = await import('../src/events.js') + mockCurrentUser.mockReturnValue(gotrueUserWithUpdate()) + mockUpdate.mockResolvedValue(makeGoTrueUser()) + + const cb = vi.fn() + onAuthChange(cb) + await updateUser({ data: { full_name: 'New Name' } }) + + expect(cb).toHaveBeenCalledWith( + 'user_updated', + expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + ) + }) + + it('throws AuthError when no user is logged in', async () => { + const { updateUser } = await import('../src/account.js') + const { AuthError } = await import('../src/errors.js') + mockCurrentUser.mockReturnValue(null) + + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + + const error = await updateUser({ data: { full_name: 'New Name' } }).catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('No user is currently logged in') + + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + }) + + it('auto-hydrates from cookies when no session but cookies exist', async () => { + const { updateUser } = await import('../src/account.js') + + const hydratedUser = gotrueUserWithUpdate() + mockCurrentUser + .mockReturnValueOnce(null) // first check: no session + .mockReturnValueOnce(null) // hydrateSession internal check + .mockReturnValueOnce(hydratedUser) // after hydration + .mockReturnValueOnce(hydratedUser) // startTokenRefresh reads currentUser + mockCreateUser.mockResolvedValue(makeGoTrueUser()) + mockUpdate.mockResolvedValue(makeGoTrueUser({ user_metadata: { full_name: 'New Name' } })) + + Object.defineProperty(document, 'cookie', { + writable: true, + value: 'nf_jwt=test-access-token; nf_refresh=test-refresh-token', + }) + + const user = await updateUser({ data: { full_name: 'New Name' } }) + + expect(mockCreateUser).toHaveBeenCalled() + expect(mockUpdate).toHaveBeenCalledWith({ data: { full_name: 'New Name' } }) + expect(user.name).toBe('New Name') + + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + }) +}) diff --git a/packages/identity/prod/test/admin.browser.test.ts b/packages/identity/prod/test/admin.browser.test.ts new file mode 100644 index 00000000..c0d26566 --- /dev/null +++ b/packages/identity/prod/test/admin.browser.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://localhost" } + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' + +vi.mock('gotrue-js', () => ({ + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + default: class MockGoTrue {}, +})) + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() +}) + +describe('admin (browser)', () => { + it('admin.listUsers throws AuthError in the browser', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.listUsers().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('server-only') + }) + + it('admin.getUser throws AuthError in the browser', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.getUser('550e8400-e29b-41d4-a716-446655440000').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('server-only') + }) + + it('admin.createUser throws AuthError in the browser', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin + .createUser({ email: 'jane@example.com', password: 'password123' }) + .catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('server-only') + }) + + it('admin.updateUser throws AuthError in the browser', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin + .updateUser('550e8400-e29b-41d4-a716-446655440000', { email: 'new@example.com' }) + .catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('server-only') + }) + + it('admin.deleteUser throws AuthError in the browser', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.deleteUser('550e8400-e29b-41d4-a716-446655440000').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('server-only') + }) +}) diff --git a/packages/identity/prod/test/admin.server.test.ts b/packages/identity/prod/test/admin.server.test.ts new file mode 100644 index 00000000..784e5be9 --- /dev/null +++ b/packages/identity/prod/test/admin.server.test.ts @@ -0,0 +1,448 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' +import { makeGoTrueUser } from './fixtures.js' + +const IDENTITY_URL = 'https://example.netlify.app/.netlify/identity' +const OPERATOR_TOKEN = 'test-operator-token' + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + + globalThis.netlifyIdentityContext = { + url: IDENTITY_URL, + token: OPERATOR_TOKEN, + } +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() + vi.unstubAllGlobals() + delete globalThis.netlifyIdentityContext +}) + +describe('admin.listUsers (server)', () => { + it('GETs /admin/users with operator token and returns User[]', async () => { + const users = [ + makeGoTrueUser(), + makeGoTrueUser({ id: '661e8400-e29b-41d4-a716-446655440001', email: 'bob@example.com' }), + ] + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ users }), + }), + ) + + const { admin } = await import('../src/admin.js') + const result = await admin.listUsers() + + expect(fetch).toHaveBeenCalledWith( + `${IDENTITY_URL}/admin/users`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${OPERATOR_TOKEN}`, + 'Content-Type': 'application/json', + }), + }), + ) + + expect(result).toHaveLength(2) + expect(result[0].id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(result[1].id).toBe('661e8400-e29b-41d4-a716-446655440001') + }) + + it('appends pagination query params when provided', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ users: [] }), + }), + ) + + const { admin } = await import('../src/admin.js') + await admin.listUsers({ page: 2, perPage: 10 }) + + expect(fetch).toHaveBeenCalledWith(`${IDENTITY_URL}/admin/users?page=2&per_page=10`, expect.any(Object)) + }) + + it('throws AuthError on HTTP failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve({ msg: 'Forbidden' }), + }), + ) + + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.listUsers().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toBe('Forbidden') + expect((error as InstanceType).status).toBe(403) + }) +}) + +describe('admin.getUser (server)', () => { + it('GETs /admin/users/:id and returns User', async () => { + const userData = makeGoTrueUser() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(userData), + }), + ) + + const { admin } = await import('../src/admin.js') + const result = await admin.getUser('550e8400-e29b-41d4-a716-446655440000') + + expect(fetch).toHaveBeenCalledWith( + `${IDENTITY_URL}/admin/users/550e8400-e29b-41d4-a716-446655440000`, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: `Bearer ${OPERATOR_TOKEN}` }), + }), + ) + expect(result.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(result.email).toBe('jane@example.com') + }) + + it('throws AuthError on 404', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: () => Promise.resolve({ msg: 'User not found' }), + }), + ) + + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.getUser('00000000-0000-0000-0000-000000000000').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).status).toBe(404) + }) +}) + +describe('admin.createUser (server)', () => { + it('POSTs to /admin/users with JSON body and returns User', async () => { + const userData = makeGoTrueUser() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(userData), + }), + ) + + const { admin } = await import('../src/admin.js') + const result = await admin.createUser({ + email: 'jane@example.com', + password: 'password123', + data: { role: 'editor', user_metadata: { full_name: 'Jane Doe' } }, + }) + + const callArgs = vi.mocked(fetch).mock.calls[0] + const body = JSON.parse(callArgs[1]?.body as string) + expect(body).toEqual({ + email: 'jane@example.com', + password: 'password123', + role: 'editor', + user_metadata: { full_name: 'Jane Doe' }, + confirm: true, + }) + + expect(result.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('works without optional data parameter', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeGoTrueUser()), + }), + ) + + const { admin } = await import('../src/admin.js') + await admin.createUser({ email: 'jane@example.com', password: 'password123' }) + + const callArgs = vi.mocked(fetch).mock.calls[0] + const body = JSON.parse(callArgs[1]?.body as string) + expect(body).toEqual({ + email: 'jane@example.com', + password: 'password123', + confirm: true, + }) + }) + + it('does not allow data to override email, password, or confirm', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeGoTrueUser()), + }), + ) + + const { admin } = await import('../src/admin.js') + await admin.createUser({ + email: 'jane@example.com', + password: 'password123', + data: { email: 'attacker@evil.com', password: 'hacked', confirm: false }, + }) + + const callArgs = vi.mocked(fetch).mock.calls[0] + const body = JSON.parse(callArgs[1]?.body as string) + expect(body.email).toBe('jane@example.com') + expect(body.password).toBe('password123') + expect(body.confirm).toBe(true) + }) + + it('silently ignores unrecognized keys in data', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeGoTrueUser()), + }), + ) + + const { admin } = await import('../src/admin.js') + await admin.createUser({ + email: 'jane@example.com', + password: 'password123', + data: { full_name: 'Jane Doe', arbitrary_field: 'ignored' }, + }) + + const callArgs = vi.mocked(fetch).mock.calls[0] + const body = JSON.parse(callArgs[1]?.body as string) + expect(body).toEqual({ + email: 'jane@example.com', + password: 'password123', + confirm: true, + }) + }) + + it('forwards all allowed GoTrue fields from data', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeGoTrueUser()), + }), + ) + + const { admin } = await import('../src/admin.js') + await admin.createUser({ + email: 'jane@example.com', + password: 'password123', + data: { + role: 'editor', + app_metadata: { plan: 'pro' }, + user_metadata: { full_name: 'Jane Doe' }, + }, + }) + + const callArgs = vi.mocked(fetch).mock.calls[0] + const body = JSON.parse(callArgs[1]?.body as string) + expect(body).toEqual({ + email: 'jane@example.com', + password: 'password123', + confirm: true, + role: 'editor', + app_metadata: { plan: 'pro' }, + user_metadata: { full_name: 'Jane Doe' }, + }) + }) +}) + +describe('admin.updateUser (server)', () => { + it('PUTs to /admin/users/:id with attributes and returns User', async () => { + const userData = makeGoTrueUser({ email: 'updated@example.com' }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(userData), + }), + ) + + const { admin } = await import('../src/admin.js') + const result = await admin.updateUser('550e8400-e29b-41d4-a716-446655440000', { email: 'updated@example.com' }) + + expect(fetch).toHaveBeenCalledWith( + `${IDENTITY_URL}/admin/users/550e8400-e29b-41d4-a716-446655440000`, + expect.objectContaining({ method: 'PUT' }), + ) + + const callArgs = vi.mocked(fetch).mock.calls[0] + const body = JSON.parse(callArgs[1]?.body as string) + expect(body).toEqual({ email: 'updated@example.com' }) + + expect(result.email).toBe('updated@example.com') + }) +}) + +describe('admin.deleteUser (server)', () => { + it('DELETEs /admin/users/:id and returns void', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }), + ) + + const { admin } = await import('../src/admin.js') + await admin.deleteUser('550e8400-e29b-41d4-a716-446655440000') + + expect(fetch).toHaveBeenCalledWith( + `${IDENTITY_URL}/admin/users/550e8400-e29b-41d4-a716-446655440000`, + expect.objectContaining({ method: 'DELETE' }), + ) + }) + + it('throws AuthError on failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.resolve({ msg: 'Internal error' }), + }), + ) + + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.deleteUser('550e8400-e29b-41d4-a716-446655440000').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).status).toBe(500) + }) +}) + +describe('admin error handling (server)', () => { + it('throws AuthError when operator token is missing', async () => { + globalThis.netlifyIdentityContext = { url: IDENTITY_URL } + + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.listUsers().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('operator token') + }) + + it('throws AuthError when identity URL is missing', async () => { + delete globalThis.netlifyIdentityContext + + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.getUser('550e8400-e29b-41d4-a716-446655440000').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('Identity endpoint URL') + }) + + it('throws AuthError when fetch itself fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.listUsers().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toBe('Network error') + }) + + it('uses fallback message when error response has no msg field', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 502, + json: () => Promise.resolve({}), + }), + ) + + const { admin } = await import('../src/admin.js') + + const error = (await admin.listUsers().catch((e: unknown) => e)) as Error + expect(error.message).toBe('Admin request failed (502)') + }) +}) + +describe('userId validation (sanitizeUserId)', () => { + it('accepts a valid lowercase UUID', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeGoTrueUser()), + }), + ) + + const { admin } = await import('../src/admin.js') + const result = await admin.getUser('550e8400-e29b-41d4-a716-446655440000') + + expect(result.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('accepts a valid uppercase UUID', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeGoTrueUser()), + }), + ) + + const { admin } = await import('../src/admin.js') + const result = await admin.getUser('550E8400-E29B-41D4-A716-446655440000') + + expect(result.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('rejects path traversal strings', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.getUser('../../settings').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('not a valid UUID') + }) + + it('rejects an empty string', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.getUser('').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as InstanceType).message).toContain('not a valid UUID') + }) + + it('rejects a UUID with extra characters appended', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.getUser('550e8400-e29b-41d4-a716-446655440000-extra').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + }) + + it('rejects a non-hex string in UUID format', async () => { + const { admin } = await import('../src/admin.js') + const { AuthError } = await import('../src/errors.js') + + const error = await admin.getUser('zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + }) +}) diff --git a/packages/identity/prod/test/auth.browser.test.ts b/packages/identity/prod/test/auth.browser.test.ts new file mode 100644 index 00000000..abec6b58 --- /dev/null +++ b/packages/identity/prod/test/auth.browser.test.ts @@ -0,0 +1,594 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://localhost" } + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' +import { makeGoTrueUser, clearBrowserAuthCookies } from './fixtures.js' + +const mockLogin = vi.fn() +const mockSignup = vi.fn() +const mockLogout = vi.fn() +const mockUpdate = vi.fn() +const mockCurrentUser = vi.fn() +const mockLoginExternalUrl = vi.fn() +const mockCreateUser = vi.fn() +const mockConfirm = vi.fn() +const mockRecover = vi.fn() +const mockJwt = vi.fn() + +vi.mock('gotrue-js', () => ({ + default: class MockGoTrue { + login = mockLogin + signup = mockSignup + currentUser = mockCurrentUser + loginExternalUrl = mockLoginExternalUrl + createUser = mockCreateUser + confirm = mockConfirm + recover = mockRecover + }, +})) + +const mockTokenDetails = { + access_token: 'test-jwt-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + expires_at: Math.floor(Date.now() / 1000) + 3600, +} + +const gotrueUserWithJwt = (overrides = {}) => { + const user = makeGoTrueUser(overrides) + return { ...user, jwt: mockJwt, tokenDetails: () => mockTokenDetails } +} + +const gotrueUserWithMethods = (overrides = {}) => { + const user = makeGoTrueUser(overrides) + return { ...user, logout: mockLogout, update: mockUpdate, jwt: mockJwt, tokenDetails: () => mockTokenDetails } +} + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + mockJwt.mockResolvedValue('test-jwt-token') +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() + window.location.hash = '' + clearBrowserAuthCookies() +}) + +describe('login', () => { + it('returns a normalized User', async () => { + const { login } = await import('../src/auth.js') + mockLogin.mockResolvedValue(gotrueUserWithJwt()) + + const user = await login('jane@example.com', 'password123') + + expect(mockLogin).toHaveBeenCalledWith('jane@example.com', 'password123', true) + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.email).toBe('jane@example.com') + expect(user.provider).toBe('github') + }) + + it('emits a login event', async () => { + const { login } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockLogin.mockResolvedValue(gotrueUserWithJwt()) + + const cb = vi.fn() + onAuthChange(cb) + await login('jane@example.com', 'password123') + + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) + + it('wraps gotrue-js errors in AuthError', async () => { + const { login } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + mockLogin.mockRejectedValue(new Error('Invalid credentials')) + + const error = await login('jane@example.com', 'wrong').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Invalid credentials') + }) +}) + +describe('signup', () => { + it('returns a normalized User', async () => { + const { signup } = await import('../src/auth.js') + mockSignup.mockResolvedValue(makeGoTrueUser()) + + const user = await signup('jane@example.com', 'password123') + + expect(mockSignup).toHaveBeenCalledWith('jane@example.com', 'password123', undefined) + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('passes user data to gotrue-js', async () => { + const { signup } = await import('../src/auth.js') + mockSignup.mockResolvedValue(makeGoTrueUser()) + + await signup('jane@example.com', 'password123', { full_name: 'Jane Doe' }) + + expect(mockSignup).toHaveBeenCalledWith('jane@example.com', 'password123', { + full_name: 'Jane Doe', + }) + }) + + it('emits login event when autoconfirm is on', async () => { + const { signup } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockSignup.mockResolvedValue(makeGoTrueUser({ confirmed_at: '2026-01-01T00:00:00Z' })) + + const cb = vi.fn() + onAuthChange(cb) + await signup('jane@example.com', 'password123') + + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) + + it('sets nf_jwt cookie when autoconfirm is on', async () => { + const { signup } = await import('../src/auth.js') + mockJwt.mockResolvedValue('autoconfirm-jwt-token') + mockSignup.mockResolvedValue({ + ...makeGoTrueUser({ confirmed_at: '2026-01-01T00:00:00Z' }), + jwt: mockJwt, + tokenDetails: () => ({ ...mockTokenDetails, access_token: 'autoconfirm-jwt-token' }), + }) + + await signup('jane@example.com', 'password123') + + expect(document.cookie).toContain('nf_jwt=autoconfirm-jwt-token') + }) + + it('does not set cookie when confirmation is required', async () => { + const { signup } = await import('../src/auth.js') + mockSignup.mockResolvedValue(makeGoTrueUser({ confirmed_at: null })) + + await signup('jane@example.com', 'password123') + + expect(document.cookie).not.toContain('nf_jwt') + }) + + it('does not emit login event when confirmation is required', async () => { + const { signup } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockSignup.mockResolvedValue(makeGoTrueUser({ confirmed_at: null })) + + const cb = vi.fn() + onAuthChange(cb) + await signup('jane@example.com', 'password123') + + expect(cb).not.toHaveBeenCalled() + }) +}) + +describe('logout', () => { + it('calls currentUser().logout()', async () => { + const { logout } = await import('../src/auth.js') + const mockUser = gotrueUserWithMethods() + mockCurrentUser.mockReturnValue(mockUser) + mockLogout.mockResolvedValue(undefined) + + await logout() + + expect(mockLogout).toHaveBeenCalled() + }) + + it('emits a logout event', async () => { + const { logout } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockCurrentUser.mockReturnValue(gotrueUserWithMethods()) + mockLogout.mockResolvedValue(undefined) + + const cb = vi.fn() + onAuthChange(cb) + await logout() + + expect(cb).toHaveBeenCalledWith('logout', null) + }) + + it('emits logout even when no current user', async () => { + const { logout } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockCurrentUser.mockReturnValue(null) + + const cb = vi.fn() + onAuthChange(cb) + await logout() + + expect(cb).toHaveBeenCalledWith('logout', null) + expect(mockLogout).not.toHaveBeenCalled() + }) +}) + +describe('oauthLogin', () => { + it('calls loginExternalUrl with the provider', async () => { + const { oauthLogin } = await import('../src/auth.js') + mockLoginExternalUrl.mockReturnValue('https://github.com/login/oauth/authorize?...') + + expect(() => oauthLogin('github')).toThrow('Redirecting to OAuth provider') + expect(mockLoginExternalUrl).toHaveBeenCalledWith('github') + }) +}) + +describe('onAuthChange', () => { + it('returns an unsubscribe function', async () => { + const { login } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockLogin.mockResolvedValue(gotrueUserWithJwt()) + + const cb = vi.fn() + const unsub = onAuthChange(cb) + + unsub() + await login('jane@example.com', 'password123') + + expect(cb).not.toHaveBeenCalled() + }) + + it('supports multiple subscribers', async () => { + const { login } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockLogin.mockResolvedValue(gotrueUserWithJwt()) + + const cb1 = vi.fn() + const cb2 = vi.fn() + onAuthChange(cb1) + onAuthChange(cb2) + await login('jane@example.com', 'password123') + + expect(cb1).toHaveBeenCalledOnce() + expect(cb2).toHaveBeenCalledOnce() + }) + + it('fires on cross-tab storage events', async () => { + const { onAuthChange } = await import('../src/events.js') + mockCurrentUser.mockReturnValue(makeGoTrueUser()) + + const cb = vi.fn() + onAuthChange(cb) + + window.dispatchEvent( + new StorageEvent('storage', { + key: 'gotrue.user', + newValue: '{"id":"550e8400-e29b-41d4-a716-446655440000"}', + }), + ) + + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) + + it('fires logout on cross-tab session removal', async () => { + const { onAuthChange } = await import('../src/events.js') + + const cb = vi.fn() + onAuthChange(cb) + + window.dispatchEvent( + new StorageEvent('storage', { + key: 'gotrue.user', + newValue: null, + }), + ) + + expect(cb).toHaveBeenCalledWith('logout', null) + }) + + it('ignores storage events for other keys', async () => { + const { onAuthChange } = await import('../src/events.js') + + const cb = vi.fn() + onAuthChange(cb) + + window.dispatchEvent( + new StorageEvent('storage', { + key: 'some-other-key', + newValue: 'whatever', + }), + ) + + expect(cb).not.toHaveBeenCalled() + }) + + it('continues notifying other listeners when one throws', async () => { + const { login } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockLogin.mockResolvedValue(gotrueUserWithJwt()) + + const throwingCb = vi.fn(() => { + throw new Error('subscriber error') + }) + const survivingCb = vi.fn() + onAuthChange(throwingCb) + onAuthChange(survivingCb) + await login('jane@example.com', 'password123') + + expect(throwingCb).toHaveBeenCalledOnce() + expect(survivingCb).toHaveBeenCalledWith( + 'login', + expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + ) + }) +}) + +describe('hydrateSession', () => { + it('reads cookies and calls createUser to hydrate the session', async () => { + const { hydrateSession } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockCurrentUser.mockReturnValue(null) + mockCreateUser.mockResolvedValue(makeGoTrueUser()) + + Object.defineProperty(document, 'cookie', { + writable: true, + value: 'nf_jwt=test-access-token; nf_refresh=test-refresh-token', + }) + + const cb = vi.fn() + onAuthChange(cb) + const user = await hydrateSession() + + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + token_type: 'bearer', + }), + true, + ) + expect(user).toEqual(expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + }) + + it('is a no-op when a session already exists', async () => { + const { hydrateSession } = await import('../src/auth.js') + mockCurrentUser.mockReturnValue(gotrueUserWithJwt()) + + const user = await hydrateSession() + + expect(mockCreateUser).not.toHaveBeenCalled() + expect(user).toEqual(expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) + + it('returns null when no cookies are present', async () => { + const { hydrateSession } = await import('../src/auth.js') + mockCurrentUser.mockReturnValue(null) + + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + + const user = await hydrateSession() + + expect(mockCreateUser).not.toHaveBeenCalled() + expect(user).toBeNull() + }) + + it('uses fallback expiry when JWT payload is not decodable', async () => { + const { hydrateSession } = await import('../src/auth.js') + mockCurrentUser.mockReturnValue(null) + mockCreateUser.mockResolvedValue(makeGoTrueUser()) + + Object.defineProperty(document, 'cookie', { writable: true, value: 'nf_jwt=not-a-valid-jwt' }) + + await hydrateSession() + + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'not-a-valid-jwt', + token_type: 'bearer', + expires_in: expect.any(Number), + expires_at: expect.any(Number), + }), + true, + ) + const callArgs = mockCreateUser.mock.calls[0][0] + expect(callArgs.expires_in).toBeGreaterThan(0) + expect(callArgs.expires_in).toBeLessThanOrEqual(3600) + + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + }) + + it('hydrates with empty refresh token when only nf_jwt is present', async () => { + const { hydrateSession } = await import('../src/auth.js') + mockCurrentUser.mockReturnValue(null) + mockCreateUser.mockResolvedValue(makeGoTrueUser()) + + Object.defineProperty(document, 'cookie', { writable: true, value: 'nf_jwt=test-access-token' }) + + await hydrateSession() + + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'test-access-token', + refresh_token: '', + }), + true, + ) + + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + }) +}) + +describe('handleAuthCallback', () => { + it('returns null when there is no hash', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + + const result = await handleAuthCallback() + expect(result).toBeNull() + }) + + it('handles OAuth access_token', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockCreateUser.mockResolvedValue(makeGoTrueUser()) + + window.location.hash = + '#access_token=test-token&token_type=bearer&expires_in=3600&expires_at=9999999999&refresh_token=refresh-123' + + const cb = vi.fn() + onAuthChange(cb) + const result = await handleAuthCallback() + + expect(result).toEqual({ + type: 'oauth', + user: expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + }) + expect(mockCreateUser).toHaveBeenCalledWith( + { + access_token: 'test-token', + token_type: 'bearer', + expires_in: 3600, + expires_at: 9999999999, + refresh_token: 'refresh-123', + }, + true, + ) + expect(cb).toHaveBeenCalledWith('login', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + expect(window.location.hash).toBe('') + }) + + it('handles confirmation_token', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + mockConfirm.mockResolvedValue(gotrueUserWithJwt()) + + window.location.hash = '#confirmation_token=confirm-abc' + + const result = await handleAuthCallback() + + expect(result).toEqual({ + type: 'confirmation', + user: expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + }) + expect(mockConfirm).toHaveBeenCalledWith('confirm-abc', true) + }) + + it('handles recovery_token', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + mockRecover.mockResolvedValue(gotrueUserWithJwt()) + + const cb = vi.fn() + onAuthChange(cb) + + window.location.hash = '#recovery_token=recover-abc' + + const result = await handleAuthCallback() + + expect(result).toEqual({ + type: 'recovery', + user: expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + }) + expect(mockRecover).toHaveBeenCalledWith('recover-abc', true) + expect(cb).toHaveBeenCalledWith('recovery', expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' })) + }) + + it('handles invite_token without completing the flow', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + + window.location.hash = '#invite_token=invite-abc' + + const result = await handleAuthCallback() + + expect(result).toEqual({ + type: 'invite', + user: null, + token: 'invite-abc', + }) + }) + + it('handles email_change_token by calling PUT /user', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + const { onAuthChange } = await import('../src/events.js') + const userData = makeGoTrueUser() + mockCurrentUser.mockReturnValue({ jwt: mockJwt }) + mockJwt.mockResolvedValue('test-jwt-token') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(userData), + }), + ) + + window.location.hash = '#email_change_token=change-abc' + + const cb = vi.fn() + onAuthChange(cb) + const result = await handleAuthCallback() + + expect(result).toEqual({ + type: 'email_change', + user: expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + }) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/.netlify/identity/user'), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ email_change_token: 'change-abc' }), + }), + ) + expect(cb).toHaveBeenCalledWith( + 'user_updated', + expect.objectContaining({ id: '550e8400-e29b-41d4-a716-446655440000' }), + ) + }) + + it('throws AuthError when email_change API returns an error', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + mockCurrentUser.mockReturnValue({ jwt: mockJwt }) + mockJwt.mockResolvedValue('test-jwt-token') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 422, + json: () => Promise.resolve({ msg: 'Invalid email change token' }), + }), + ) + + window.location.hash = '#email_change_token=bad-token' + + const error = await handleAuthCallback().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Invalid email change token') + expect(error.status).toBe(422) + }) + + it('throws AuthError for email_change_token when no session exists', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + mockCurrentUser.mockReturnValue(null) + + window.location.hash = '#email_change_token=change-abc' + + const error = await handleAuthCallback().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Email change verification requires an active browser session') + }) + + it('clears the URL hash after handling', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + mockConfirm.mockResolvedValue(gotrueUserWithJwt()) + + window.location.hash = '#confirmation_token=confirm-abc' + await handleAuthCallback() + + expect(window.location.hash).toBe('') + }) + + it('wraps errors in AuthError', async () => { + const { handleAuthCallback } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + mockConfirm.mockRejectedValue(new Error('Invalid token')) + + window.location.hash = '#confirmation_token=bad-token' + + const error = await handleAuthCallback().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Invalid token') + }) +}) diff --git a/packages/identity/prod/test/auth.server.test.ts b/packages/identity/prod/test/auth.server.test.ts new file mode 100644 index 00000000..8e5cd052 --- /dev/null +++ b/packages/identity/prod/test/auth.server.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' +import { makeGoTrueUser } from './fixtures.js' + +const mockCookies = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +} + +const IDENTITY_URL = 'https://example.netlify.app/.netlify/identity' + +/** Builds a fake JWT (header.payload.signature) from claims. */ +const fakeJwt = (claims: Record): string => { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const payload = btoa(JSON.stringify(claims)) + return `${header}.${payload}.fake-signature` +} + +const TEST_JWT_CLAIMS = { + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + exp: Math.floor(Date.now() / 1000) + 3600, + app_metadata: { provider: 'github' }, + user_metadata: { full_name: 'Jane Doe' }, +} + +const TEST_ACCESS_TOKEN = fakeJwt(TEST_JWT_CLAIMS) + +const mockTokenResponse = () => ({ + access_token: TEST_ACCESS_TOKEN, + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'test-refresh-token', +}) + +const mockGoTrueSignupResponse = (overrides: Record = {}) => { + const user = makeGoTrueUser() + return { ...user, ...overrides } +} + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + + globalThis.netlifyIdentityContext = { + url: IDENTITY_URL, + token: 'test-operator-token', + } + + globalThis.Netlify = { + context: { + cookies: mockCookies, + }, + } as unknown as typeof globalThis.Netlify +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() + vi.unstubAllGlobals() + delete globalThis.netlifyIdentityContext + delete (globalThis as Record).Netlify +}) + +describe('login (server)', () => { + it('POSTs to /token then GETs /user and sets nf_jwt cookie', async () => { + const userData = makeGoTrueUser() + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse()), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(userData), + }), + ) + + const { login } = await import('../src/auth.js') + const user = await login('jane@example.com', 'password123') + + expect(fetch).toHaveBeenCalledTimes(2) + + expect(fetch).toHaveBeenNthCalledWith( + 1, + `${IDENTITY_URL}/token`, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }), + ) + + const callArgs = vi.mocked(fetch).mock.calls[0] + const body = callArgs[1]?.body as string + expect(body).toContain('grant_type=password') + expect(body).toContain('username=jane%40example.com') + expect(body).toContain('password=password123') + + expect(fetch).toHaveBeenNthCalledWith( + 2, + `${IDENTITY_URL}/user`, + expect.objectContaining({ + headers: { Authorization: `Bearer ${TEST_ACCESS_TOKEN}` }, + }), + ) + + expect(mockCookies.set).toHaveBeenCalledWith({ + name: 'nf_jwt', + value: TEST_ACCESS_TOKEN, + httpOnly: false, + secure: true, + path: '/', + sameSite: 'Lax', + }) + + expect(mockCookies.set).toHaveBeenCalledWith({ + name: 'nf_refresh', + value: 'test-refresh-token', + httpOnly: false, + secure: true, + path: '/', + sameSite: 'Lax', + }) + + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.email).toBe('jane@example.com') + }) + + it('returns a User with real fields from the /user endpoint', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse()), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve( + makeGoTrueUser({ + user_metadata: { full_name: 'Jane Doe', avatar_url: 'https://example.com/avatar.png' }, + }), + ), + }), + ) + + const { login } = await import('../src/auth.js') + const user = await login('jane@example.com', 'password123') + + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.email).toBe('jane@example.com') + expect(user.name).toBe('Jane Doe') + expect(user.pictureUrl).toBe('https://example.com/avatar.png') + expect(user.provider).toBe('github') + }) + + it('throws AuthError when /user fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse()), + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ msg: 'Invalid token' }), + }), + ) + + const { login } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + + const error = await login('jane@example.com', 'password123').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Invalid token') + expect(error.status).toBe(401) + }) + + it('throws AuthError with status on invalid credentials', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ msg: 'Invalid credentials' }), + }), + ) + + const { login } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + + const error = await login('jane@example.com', 'wrong').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Invalid credentials') + expect(error.status).toBe(401) + }) + + it('throws AuthError when fetch itself fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + + const { login } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + + const error = await login('jane@example.com', 'password123').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Network error') + }) + + it('throws AuthError when Netlify.context.cookies is not available', async () => { + delete (globalThis as Record).Netlify + + const { login } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + + const error = await login('jane@example.com', 'password123').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Server-side auth requires Netlify Functions runtime') + }) +}) + +describe('signup (server)', () => { + it('POSTs to /signup with JSON body and returns normalized User', async () => { + const signupResponse = mockGoTrueSignupResponse({ confirmed_at: null }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(signupResponse), + }), + ) + + const { signup } = await import('../src/auth.js') + const user = await signup('jane@example.com', 'password123', { full_name: 'Jane Doe' }) + + expect(fetch).toHaveBeenCalledWith( + `${IDENTITY_URL}/signup`, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'jane@example.com', password: 'password123', data: { full_name: 'Jane Doe' } }), + }), + ) + + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.email).toBe('jane@example.com') + expect(mockCookies.set).not.toHaveBeenCalled() + }) + + it('sets nf_jwt and nf_refresh cookies when autoconfirm is on', async () => { + const autoConfirmToken = fakeJwt(TEST_JWT_CLAIMS) + const signupResponse = mockGoTrueSignupResponse({ + confirmed_at: '2026-01-01T00:00:00Z', + access_token: autoConfirmToken, + refresh_token: 'auto-confirm-refresh', + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(signupResponse), + }), + ) + + const { signup } = await import('../src/auth.js') + await signup('jane@example.com', 'password123') + + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'nf_jwt', + value: autoConfirmToken, + httpOnly: false, + }), + ) + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'nf_refresh', + value: 'auto-confirm-refresh', + httpOnly: false, + }), + ) + }) + + it('does not set cookie when autoconfirm is off', async () => { + const signupResponse = mockGoTrueSignupResponse({ confirmed_at: null }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(signupResponse), + }), + ) + + const { signup } = await import('../src/auth.js') + await signup('jane@example.com', 'password123') + + expect(mockCookies.set).not.toHaveBeenCalled() + }) + + it('does not set cookie when confirmed but no access_token', async () => { + const signupResponse = mockGoTrueSignupResponse({ + confirmed_at: '2026-01-01T00:00:00Z', + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(signupResponse), + }), + ) + + const { signup } = await import('../src/auth.js') + await signup('jane@example.com', 'password123') + + expect(mockCookies.set).not.toHaveBeenCalled() + }) + + it('throws AuthError on failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 422, + json: () => Promise.resolve({ msg: 'User already exists' }), + }), + ) + + const { signup } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + + const error = await signup('jane@example.com', 'password123').catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('User already exists') + expect(error.status).toBe(422) + }) +}) + +describe('logout (server)', () => { + it('POSTs to /logout with Bearer token and deletes both cookies', async () => { + mockCookies.get.mockReturnValue('existing-jwt-token') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + + const { logout } = await import('../src/auth.js') + await logout() + + expect(fetch).toHaveBeenCalledWith( + `${IDENTITY_URL}/logout`, + expect.objectContaining({ + method: 'POST', + headers: { Authorization: 'Bearer existing-jwt-token' }, + }), + ) + + expect(mockCookies.delete).toHaveBeenCalledWith('nf_jwt') + expect(mockCookies.delete).toHaveBeenCalledWith('nf_refresh') + }) + + it('deletes both cookies even when no JWT exists (skip /logout call)', async () => { + mockCookies.get.mockReturnValue(undefined) + vi.stubGlobal('fetch', vi.fn()) + + const { logout } = await import('../src/auth.js') + await logout() + + expect(fetch).not.toHaveBeenCalled() + expect(mockCookies.delete).toHaveBeenCalledWith('nf_jwt') + expect(mockCookies.delete).toHaveBeenCalledWith('nf_refresh') + }) + + it('still deletes cookies when fetch fails', async () => { + mockCookies.get.mockReturnValue('existing-jwt-token') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + + const { logout } = await import('../src/auth.js') + + await logout() + + expect(mockCookies.delete).toHaveBeenCalledWith('nf_jwt') + expect(mockCookies.delete).toHaveBeenCalledWith('nf_refresh') + }) + + it('throws AuthError when Netlify.context.cookies is not available', async () => { + delete (globalThis as Record).Netlify + + const { logout } = await import('../src/auth.js') + const { AuthError } = await import('../src/errors.js') + + const error = await logout().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Server-side auth requires Netlify Functions runtime') + }) +}) diff --git a/packages/identity/prod/test/config.browser.test.ts b/packages/identity/prod/test/config.browser.test.ts new file mode 100644 index 00000000..42578a45 --- /dev/null +++ b/packages/identity/prod/test/config.browser.test.ts @@ -0,0 +1,20 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://localhost" } + */ +import { describe, it, expect, afterEach } from 'vitest' +import { getIdentityConfig } from '../src/main.js' +import { resetTestGoTrueClient } from '../src/environment.js' + +describe('getIdentityConfig (browser)', () => { + afterEach(() => { + resetTestGoTrueClient() + }) + + it('returns config with URL from window origin', () => { + const config = getIdentityConfig() + if (!config) throw new Error('expected config to not be null') + expect(config.url).toContain('/.netlify/identity') + expect(config.token).toBeUndefined() + }) +}) diff --git a/packages/identity/prod/test/config.test.ts b/packages/identity/prod/test/config.test.ts new file mode 100644 index 00000000..9795fb08 --- /dev/null +++ b/packages/identity/prod/test/config.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' + +vi.mock('gotrue-js', () => { + const mockSettings = vi.fn() + return { + default: class MockGoTrue { + settings = mockSettings + }, + __mockSettings: mockSettings, + } +}) + +const getMockSettings = async () => { + const mod = await import('gotrue-js') + return (mod as unknown as { __mockSettings: ReturnType }).__mockSettings +} + +describe('getIdentityConfig (server)', () => { + beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + }) + + afterEach(() => { + delete globalThis.netlifyIdentityContext + delete globalThis.Netlify + resetTestGoTrueClient() + }) + + it('returns null outside Netlify environment', async () => { + const { getIdentityConfig } = await import('../src/main.js') + expect(getIdentityConfig()).toBeNull() + }) + + it('returns config from identity context', async () => { + globalThis.netlifyIdentityContext = { + url: 'https://example.com/.netlify/identity', + token: 'op-token', + } + const { getIdentityConfig } = await import('../src/main.js') + const config = getIdentityConfig() + expect(config).toEqual({ + url: 'https://example.com/.netlify/identity', + token: 'op-token', + }) + }) + + it('falls back to Netlify.context.url when netlifyIdentityContext has no url', async () => { + globalThis.netlifyIdentityContext = { + user: { + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + }, + } + globalThis.Netlify = { context: { url: 'https://example.netlify.app' } } + const { getIdentityConfig } = await import('../src/main.js') + const config = getIdentityConfig() + expect(config).toEqual({ + url: 'https://example.netlify.app/.netlify/identity', + }) + }) +}) + +describe('getSettings', () => { + beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + }) + + afterEach(() => { + delete globalThis.netlifyIdentityContext + resetTestGoTrueClient() + vi.resetAllMocks() + }) + + it('throws MissingIdentityError when no client is available', async () => { + const { getSettings, MissingIdentityError } = await import('../src/main.js') + await expect(getSettings()).rejects.toThrow(MissingIdentityError) + await expect(getSettings()).rejects.toThrow('Netlify Identity is not available') + }) + + it('maps gotrue-js settings to the Settings type', async () => { + globalThis.netlifyIdentityContext = { + url: 'https://example.com/.netlify/identity', + } + + const mockSettings = await getMockSettings() + mockSettings.mockResolvedValue({ + autoconfirm: false, + disable_signup: true, + external: { + google: true, + github: true, + gitlab: false, + bitbucket: false, + facebook: false, + email: true, + }, + }) + + const { getSettings } = await import('../src/main.js') + const settings = await getSettings() + + expect(settings.autoconfirm).toBe(false) + expect(settings.disableSignup).toBe(true) + expect(settings.providers.google).toBe(true) + expect(settings.providers.github).toBe(true) + expect(settings.providers.email).toBe(true) + expect(settings.providers.gitlab).toBe(false) + }) + + it('wraps fetch errors in AuthError with status 502', async () => { + globalThis.netlifyIdentityContext = { + url: 'https://example.com/.netlify/identity', + } + + const mockSettings = await getMockSettings() + const networkError = new Error('Network error') + mockSettings.mockRejectedValue(networkError) + + const { getSettings, AuthError } = await import('../src/main.js') + const error = await getSettings().catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect(error.message).toBe('Network error') + expect(error.status).toBe(502) + expect(error.cause).toBe(networkError) + }) +}) diff --git a/packages/identity/prod/test/csrf.test.ts b/packages/identity/prod/test/csrf.test.ts new file mode 100644 index 00000000..d4d79790 --- /dev/null +++ b/packages/identity/prod/test/csrf.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { verifyRequestOrigin } from '../src/csrf.js' +import { AuthError } from '../src/errors.js' + +const makeRequest = (url: string, init?: { method?: string; origin?: string }): Request => { + const headers = new Headers() + if (init?.origin !== undefined) { + headers.set('origin', init.origin) + } + return new Request(url, { method: init?.method ?? 'POST', headers }) +} + +describe('verifyRequestOrigin', () => { + describe('default (no allowedOrigins)', () => { + it('passes when Origin matches the request URL origin', () => { + const request = makeRequest('https://example.com/api/login', { + origin: 'https://example.com', + }) + expect(() => { + verifyRequestOrigin(request) + }).not.toThrow() + }) + + it('passes when Origin matches with a non-default port', () => { + const request = makeRequest('https://example.com:8443/api/login', { + origin: 'https://example.com:8443', + }) + expect(() => { + verifyRequestOrigin(request) + }).not.toThrow() + }) + + it('throws AuthError with status 403 when Origin does not match', () => { + const request = makeRequest('https://example.com/api/login', { + origin: 'https://evil.com', + }) + + let caught: unknown + try { + verifyRequestOrigin(request) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(AuthError) + expect((caught as AuthError).status).toBe(403) + expect((caught as AuthError).message).toContain('https://evil.com') + }) + + it('throws when scheme differs (http vs https)', () => { + const request = makeRequest('https://example.com/api/login', { + origin: 'http://example.com', + }) + expect(() => { + verifyRequestOrigin(request) + }).toThrow(AuthError) + }) + + it('throws when subdomains differ', () => { + const request = makeRequest('https://app.example.com/api/login', { + origin: 'https://other.example.com', + }) + expect(() => { + verifyRequestOrigin(request) + }).toThrow(AuthError) + }) + }) + + describe('all methods are checked', () => { + it('passes on GET with same-origin', () => { + const request = makeRequest('https://example.com/api/whatever', { + method: 'GET', + origin: 'https://example.com', + }) + expect(() => { + verifyRequestOrigin(request) + }).not.toThrow() + }) + + it('throws on GET with mismatching Origin', () => { + const request = makeRequest('https://example.com/api/whatever', { + method: 'GET', + origin: 'https://evil.com', + }) + expect(() => { + verifyRequestOrigin(request) + }).toThrow(AuthError) + }) + + it('throws on HEAD with mismatching Origin', () => { + const request = makeRequest('https://example.com/api/whatever', { + method: 'HEAD', + origin: 'https://evil.com', + }) + expect(() => { + verifyRequestOrigin(request) + }).toThrow(AuthError) + }) + + it('throws on DELETE with mismatching Origin', () => { + const request = makeRequest('https://example.com/api/whatever', { + method: 'DELETE', + origin: 'https://evil.com', + }) + expect(() => { + verifyRequestOrigin(request) + }).toThrow(AuthError) + }) + }) + + describe('missing Origin header', () => { + it('throws AuthError with status 403 when Origin header is absent', () => { + const request = makeRequest('https://example.com/api/login') + + let caught: unknown + try { + verifyRequestOrigin(request) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(AuthError) + expect((caught as AuthError).status).toBe(403) + expect((caught as AuthError).message).toContain('missing Origin header') + }) + + it('throws when Origin header is empty string', () => { + const request = makeRequest('https://example.com/api/login', { origin: '' }) + expect(() => { + verifyRequestOrigin(request) + }).toThrow(AuthError) + }) + }) + + describe('allowedOrigins override', () => { + it('accepts an Origin listed in allowedOrigins', () => { + const request = makeRequest('https://app.example.com/api/login', { + origin: 'https://www.example.com', + }) + expect(() => { + verifyRequestOrigin(request, { + allowedOrigins: ['https://www.example.com', 'https://app.example.com'], + }) + }).not.toThrow() + }) + + it('rejects the request URL origin when allowedOrigins is provided and does not include it', () => { + const request = makeRequest('https://app.example.com/api/login', { + origin: 'https://app.example.com', + }) + expect(() => { + verifyRequestOrigin(request, { allowedOrigins: ['https://www.example.com'] }) + }).toThrow(AuthError) + }) + + it('rejects every Origin when allowedOrigins is an empty array', () => { + const request = makeRequest('https://example.com/api/login', { + origin: 'https://example.com', + }) + expect(() => { + verifyRequestOrigin(request, { allowedOrigins: [] }) + }).toThrow(AuthError) + }) + + it('still throws on missing Origin when allowedOrigins is set', () => { + const request = makeRequest('https://example.com/api/login') + expect(() => { + verifyRequestOrigin(request, { allowedOrigins: ['https://example.com'] }) + }).toThrow(AuthError) + }) + }) +}) diff --git a/packages/identity/prod/test/errors.test.ts b/packages/identity/prod/test/errors.test.ts new file mode 100644 index 00000000..794838ef --- /dev/null +++ b/packages/identity/prod/test/errors.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { AuthError, MissingIdentityError } from '../src/main.js' + +describe('AuthError', () => { + it('has correct name and status', () => { + const err = new AuthError('fail', 401) + expect(err.name).toBe('AuthError') + expect(err.message).toBe('fail') + expect(err.status).toBe(401) + expect(err).toBeInstanceOf(Error) + }) + + it('works without status', () => { + const err = new AuthError('oops') + expect(err.status).toBeUndefined() + }) +}) + +describe('MissingIdentityError', () => { + it('has correct name and default message', () => { + const err = new MissingIdentityError() + expect(err.name).toBe('MissingIdentityError') + expect(err.message).toBe('Netlify Identity is not available.') + }) +}) diff --git a/packages/identity/prod/test/fetch.test.ts b/packages/identity/prod/test/fetch.test.ts new file mode 100644 index 00000000..d767fb6e --- /dev/null +++ b/packages/identity/prod/test/fetch.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { fetchWithTimeout } from '../src/fetch.js' +import { AuthError } from '../src/errors.js' + +describe('fetchWithTimeout', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns the response when fetch completes within the timeout', async () => { + const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 }) + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse) + + const res = await fetchWithTimeout('https://example.com/.netlify/identity/user', { + headers: { Authorization: 'Bearer test-token' }, + }) + + expect(res).toBe(mockResponse) + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://example.com/.netlify/identity/user', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + signal: expect.any(AbortSignal), + }), + ) + }) + + it('forwards request options to fetch', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })) + + await fetchWithTimeout('https://example.com/.netlify/identity/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=password', + }) + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://example.com/.netlify/identity/token', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=password', + }), + ) + }) + + it('throws AuthError with pathname and timeout when request times out', async () => { + vi.useFakeTimers() + vi.spyOn(globalThis, 'fetch').mockImplementation( + (_url, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + }) + }), + ) + + const promise = fetchWithTimeout('https://example.com/.netlify/identity/user', {}, 100) + + vi.advanceTimersByTime(100) + + await expect(promise).rejects.toThrow(AuthError) + await expect(promise).rejects.toThrow(/\/\.netlify\/identity\/user/) + await expect(promise).rejects.toThrow(/100ms/) + + vi.useRealTimers() + }) + + it('re-throws non-timeout errors without wrapping', async () => { + const networkError = new Error('getaddrinfo ENOTFOUND example.com') + vi.spyOn(globalThis, 'fetch').mockRejectedValue(networkError) + + await expect(fetchWithTimeout('https://example.com/.netlify/identity/user')).rejects.toBe(networkError) + expect(networkError).not.toBeInstanceOf(AuthError) + }) + + it('does not leak query params in timeout error messages', async () => { + vi.useFakeTimers() + vi.spyOn(globalThis, 'fetch').mockImplementation( + (_url, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + reject(err) + }) + }), + ) + + const promise = fetchWithTimeout( + 'https://example.com/.netlify/identity/token?secret=sensitive&refresh_token=abc', + {}, + 100, + ) + + vi.advanceTimersByTime(100) + + await expect(promise).rejects.toBeInstanceOf(AuthError) + await expect(promise).rejects.toThrow(/\/\.netlify\/identity\/token/) + await expect(promise).rejects.not.toThrow(/secret=sensitive/) + await expect(promise).rejects.not.toThrow(/refresh_token=abc/) + await expect(promise).rejects.not.toThrow(/example\.com/) + + vi.useRealTimers() + }) +}) diff --git a/packages/identity/prod/test/fixtures.ts b/packages/identity/prod/test/fixtures.ts new file mode 100644 index 00000000..8be8b154 --- /dev/null +++ b/packages/identity/prod/test/fixtures.ts @@ -0,0 +1,20 @@ +import type { UserData } from 'gotrue-js' + +/** Clears nf_jwt and nf_refresh cookies via expiry. Use in afterEach for browser test files. */ +export const clearBrowserAuthCookies = (): void => { + document.cookie = 'nf_jwt=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' + document.cookie = 'nf_refresh=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' +} + +export const makeGoTrueUser = (overrides: Partial = {}): UserData => ({ + id: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + aud: '', + role: '', + confirmed_at: '2026-01-01T00:00:00Z', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-02-25T00:00:00Z', + app_metadata: { provider: 'github' }, + user_metadata: { full_name: 'Jane Doe', avatar_url: 'https://example.com/avatar.png' }, + ...overrides, +}) diff --git a/packages/identity/prod/test/nextjs.test.ts b/packages/identity/prod/test/nextjs.test.ts new file mode 100644 index 00000000..0ccc5fdc --- /dev/null +++ b/packages/identity/prod/test/nextjs.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { triggerNextjsDynamic, resetNextjsState } from '../src/nextjs.js' + +describe('triggerNextjsDynamic', () => { + afterEach(() => { + resetNextjsState() + }) + + it('is a no-op when next/headers is not available', () => { + // next/headers is not installed, so require() will fail and cache null + expect(() => { + triggerNextjsDynamic() + }).not.toThrow() + }) + + it('caches the "not available" result and skips on subsequent calls', () => { + triggerNextjsDynamic() + // Second call should return immediately + expect(() => { + triggerNextjsDynamic() + }).not.toThrow() + }) + + it('calls the headers function when available', () => { + const mockHeaders = vi.fn() + resetNextjsState(mockHeaders) + + triggerNextjsDynamic() + + expect(mockHeaders).toHaveBeenCalledOnce() + }) + + it('calls headers on every invocation (not just the first)', () => { + const mockHeaders = vi.fn() + resetNextjsState(mockHeaders) + + triggerNextjsDynamic() + triggerNextjsDynamic() + triggerNextjsDynamic() + + expect(mockHeaders).toHaveBeenCalledTimes(3) + }) + + it('re-throws DynamicServerError (error with digest property)', () => { + const dynamicError = new Error('DYNAMIC_SERVER_USAGE') + ;(dynamicError as unknown as Record).digest = 'DYNAMIC_SERVER_USAGE' + resetNextjsState(() => { + throw dynamicError + }) + + expect(() => { + triggerNextjsDynamic() + }).toThrow(dynamicError) + }) + + it('re-throws prerendering bailout errors', () => { + const bailoutError = new Error('Bail out of prerendering') + resetNextjsState(() => { + throw bailoutError + }) + + expect(() => { + triggerNextjsDynamic() + }).toThrow(bailoutError) + }) + + it('swallows non-Dynamic errors from headers()', () => { + resetNextjsState(() => { + throw new Error('some other error') + }) + + expect(() => { + triggerNextjsDynamic() + }).not.toThrow() + }) + + it('swallows non-Error throws from headers()', () => { + resetNextjsState(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- intentionally throwing a non-Error to test the handler + throw 'string error' + }) + + expect(() => { + triggerNextjsDynamic() + }).not.toThrow() + }) + + it('skips entirely when state is set to null (not Next.js)', () => { + const mockHeaders = vi.fn() + resetNextjsState(null) + + triggerNextjsDynamic() + + expect(mockHeaders).not.toHaveBeenCalled() + }) +}) diff --git a/packages/identity/prod/test/refresh.browser.test.ts b/packages/identity/prod/test/refresh.browser.test.ts new file mode 100644 index 00000000..d9c08bb2 --- /dev/null +++ b/packages/identity/prod/test/refresh.browser.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://localhost" } + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' +import { makeGoTrueUser, clearBrowserAuthCookies } from './fixtures.js' + +const mockCurrentUser = vi.fn() +const mockJwt = vi.fn() + +const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + +const mockTokenDetails = vi.fn().mockReturnValue({ + access_token: 'test-jwt-token', + refresh_token: 'test-refresh-token', + expires_in: 3600, + expires_at: futureExpiry, +}) + +vi.mock('gotrue-js', () => ({ + default: class MockGoTrue { + currentUser = mockCurrentUser + }, +})) + +const gotrueUserWithJwt = (overrides = {}) => { + const user = makeGoTrueUser(overrides) + return { ...user, jwt: mockJwt, tokenDetails: mockTokenDetails } +} + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + vi.useFakeTimers() + mockJwt.mockResolvedValue('refreshed-jwt-token') +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() + vi.useRealTimers() + clearBrowserAuthCookies() +}) + +describe('startTokenRefresh (browser)', () => { + it('schedules a refresh before token expiry', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + + const { startTokenRefresh, stopTokenRefresh } = await import('../src/refresh.js') + startTokenRefresh() + + // Token expires in 3600s, refresh margin is 60s, so timer fires at 3540s + // Advance to just before the refresh should fire + vi.advanceTimersByTime(3539 * 1000) + expect(mockJwt).not.toHaveBeenCalled() + + // Advance past the refresh point + vi.advanceTimersByTime(2 * 1000) + await vi.runOnlyPendingTimersAsync() + + expect(mockJwt).toHaveBeenCalledWith(true) + stopTokenRefresh() + }) + + it('refreshes token and syncs cookies when timer fires', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + mockTokenDetails.mockReturnValue({ + access_token: 'refreshed-jwt-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + expires_at: futureExpiry, + }) + + const { startTokenRefresh, stopTokenRefresh } = await import('../src/refresh.js') + startTokenRefresh() + + await vi.advanceTimersByTimeAsync(3541 * 1000) + await Promise.resolve() + + // Verify the refresh happened and tokenDetails were read for cookie sync + expect(mockJwt).toHaveBeenCalledWith(true) + expect(mockTokenDetails).toHaveBeenCalled() + stopTokenRefresh() + }) + + it('is a no-op when no user is logged in', async () => { + mockCurrentUser.mockReturnValue(null) + + const { startTokenRefresh } = await import('../src/refresh.js') + startTokenRefresh() + + vi.advanceTimersByTime(4000 * 1000) + expect(mockJwt).not.toHaveBeenCalled() + }) + + it('is a no-op when user has no tokenDetails', async () => { + const user = makeGoTrueUser() + mockCurrentUser.mockReturnValue({ ...user, tokenDetails: () => null }) + + const { startTokenRefresh } = await import('../src/refresh.js') + startTokenRefresh() + + vi.advanceTimersByTime(4000 * 1000) + expect(mockJwt).not.toHaveBeenCalled() + }) + + it('stops retrying when refresh fails', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + // Token expires in 10s (under 60s margin), so timer fires immediately + mockTokenDetails.mockReturnValue({ + access_token: 'old-token', + refresh_token: 'test-refresh', + expires_in: 10, + expires_at: Math.floor(Date.now() / 1000) + 10, + }) + mockJwt.mockRejectedValue(new Error('Refresh token revoked')) + + const { startTokenRefresh } = await import('../src/refresh.js') + startTokenRefresh() + + await vi.advanceTimersByTimeAsync(1) + + expect(mockJwt).toHaveBeenCalledWith(true) + + // Should not schedule another refresh after failure + mockJwt.mockClear() + await vi.advanceTimersByTimeAsync(7200 * 1000) + expect(mockJwt).not.toHaveBeenCalled() + }) + + it('restarts timer when called multiple times', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + + const { startTokenRefresh, stopTokenRefresh } = await import('../src/refresh.js') + + // First start with a far-future expiry + startTokenRefresh() + + // Now restart with a near-expiry token (fires immediately) + mockTokenDetails.mockReturnValue({ + access_token: 'old-token', + refresh_token: 'test-refresh', + expires_in: 5, + expires_at: Math.floor(Date.now() / 1000) + 5, + }) + startTokenRefresh() + + await vi.advanceTimersByTimeAsync(1) + expect(mockJwt).toHaveBeenCalledWith(true) + + stopTokenRefresh() + }) + + it('fires immediately when token is already near expiry', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + mockTokenDetails.mockReturnValue({ + access_token: 'old-token', + refresh_token: 'test-refresh', + expires_in: 30, + expires_at: Math.floor(Date.now() / 1000) + 30, // 30s left, under 60s margin + }) + + const { startTokenRefresh, stopTokenRefresh } = await import('../src/refresh.js') + startTokenRefresh() + + // delay should be max(0, ...) = 0, so fires on next tick + vi.advanceTimersByTime(1) + await vi.runOnlyPendingTimersAsync() + + expect(mockJwt).toHaveBeenCalledWith(true) + stopTokenRefresh() + }) +}) + +describe('stopTokenRefresh', () => { + it('cancels a pending refresh', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + + const { startTokenRefresh, stopTokenRefresh } = await import('../src/refresh.js') + startTokenRefresh() + stopTokenRefresh() + + vi.advanceTimersByTime(4000 * 1000) + await vi.runOnlyPendingTimersAsync() + expect(mockJwt).not.toHaveBeenCalled() + }) + + it('is safe to call when no timer is running', async () => { + const { stopTokenRefresh } = await import('../src/refresh.js') + expect(() => { + stopTokenRefresh() + }).not.toThrow() + }) +}) + +describe('refreshSession (browser)', () => { + it('calls user.jwt() and updates cookie', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBe('refreshed-jwt-token') + expect(mockJwt).toHaveBeenCalled() + expect(document.cookie).toContain('nf_jwt=refreshed-jwt-token') + }) + + it('returns null when no user is logged in', async () => { + mockCurrentUser.mockReturnValue(null) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBeNull() + }) + + it('returns null when jwt() throws', async () => { + const user = gotrueUserWithJwt() + mockCurrentUser.mockReturnValue(user) + mockJwt.mockRejectedValue(new Error('Token expired')) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBeNull() + }) +}) diff --git a/packages/identity/prod/test/refresh.server.test.ts b/packages/identity/prod/test/refresh.server.test.ts new file mode 100644 index 00000000..1b8cf4e2 --- /dev/null +++ b/packages/identity/prod/test/refresh.server.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' + +const mockCookies = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +} + +const IDENTITY_URL = 'https://example.netlify.app/.netlify/identity' + +/** Builds a fake JWT (header.payload.signature) from claims. */ +const fakeJwt = (claims: Record): string => { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const payload = btoa(JSON.stringify(claims)) + return `${header}.${payload}.fake-signature` +} + +const expiredJwt = fakeJwt({ + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + exp: Math.floor(Date.now() / 1000) - 300, // expired 5 minutes ago +}) + +const nearExpiryJwt = fakeJwt({ + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + exp: Math.floor(Date.now() / 1000) + 30, // expires in 30s (under 60s margin) +}) + +const validJwt = fakeJwt({ + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + exp: Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour +}) + +const freshAccessToken = fakeJwt({ + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + exp: Math.floor(Date.now() / 1000) + 3600, +}) + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + + globalThis.netlifyIdentityContext = { + url: IDENTITY_URL, + token: 'test-operator-token', + } + + globalThis.Netlify = { + context: { + cookies: mockCookies, + }, + } as unknown as typeof globalThis.Netlify +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() + vi.unstubAllGlobals() + delete globalThis.netlifyIdentityContext + delete (globalThis as Record).Netlify +}) + +describe('refreshSession (server)', () => { + it('refreshes an expired token and updates cookies', async () => { + mockCookies.get + .mockReturnValueOnce(expiredJwt) // nf_jwt + .mockReturnValueOnce('old-refresh-token') // nf_refresh + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: freshAccessToken, + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + }), + }), + ) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBe(freshAccessToken) + + // Verify GoTrue /token endpoint was called with refresh_token grant + expect(fetch).toHaveBeenCalledWith( + `${IDENTITY_URL}/token`, + expect.objectContaining({ + method: 'POST', + body: 'grant_type=refresh_token&refresh_token=old-refresh-token', + }), + ) + + // Verify cookies were updated + expect(mockCookies.set).toHaveBeenCalledWith(expect.objectContaining({ name: 'nf_jwt', value: freshAccessToken })) + expect(mockCookies.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'nf_refresh', value: 'new-refresh-token' }), + ) + }) + + it('refreshes a near-expiry token (within 60s margin)', async () => { + mockCookies.get.mockReturnValueOnce(nearExpiryJwt).mockReturnValueOnce('test-refresh-token') + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: freshAccessToken, + refresh_token: 'new-refresh-token', + }), + }), + ) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBe(freshAccessToken) + expect(fetch).toHaveBeenCalled() + }) + + it('returns null when token is still valid', async () => { + mockCookies.get.mockReturnValueOnce(validJwt).mockReturnValueOnce('test-refresh-token') + + vi.stubGlobal('fetch', vi.fn()) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBeNull() + expect(fetch).not.toHaveBeenCalled() + }) + + it('returns null when no nf_jwt cookie exists', async () => { + mockCookies.get.mockReturnValue(null) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBeNull() + }) + + it('returns null when no nf_refresh cookie exists', async () => { + mockCookies.get + .mockReturnValueOnce(expiredJwt) // nf_jwt present + .mockReturnValueOnce(null) // nf_refresh missing + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBeNull() + }) + + it('returns null when refresh token is invalid (401)', async () => { + mockCookies.get.mockReturnValueOnce(expiredJwt).mockReturnValueOnce('invalid-refresh-token') + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ msg: 'Invalid Refresh Token' }), + }), + ) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBeNull() + }) + + it('returns null when refresh token is invalid (400)', async () => { + mockCookies.get.mockReturnValueOnce(expiredJwt).mockReturnValueOnce('invalid-refresh-token') + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ error_description: 'Invalid Refresh Token' }), + }), + ) + + const { refreshSession } = await import('../src/refresh.js') + const result = await refreshSession() + + expect(result).toBeNull() + }) + + it('throws AuthError on server error (500)', async () => { + mockCookies.get.mockReturnValueOnce(expiredJwt).mockReturnValueOnce('test-refresh-token') + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({ msg: 'Internal Server Error' }), + }), + ) + + const { refreshSession } = await import('../src/refresh.js') + const { AuthError } = await import('../src/errors.js') + + await expect(refreshSession()).rejects.toThrow(AuthError) + }) + + it('throws AuthError on network failure', async () => { + mockCookies.get.mockReturnValueOnce(expiredJwt).mockReturnValueOnce('test-refresh-token') + + vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new TypeError('fetch failed'))) + + const { refreshSession } = await import('../src/refresh.js') + const { AuthError } = await import('../src/errors.js') + + await expect(refreshSession()).rejects.toThrow(AuthError) + }) + + it('throws AuthError when identity URL cannot be determined', async () => { + mockCookies.get.mockReturnValueOnce(expiredJwt).mockReturnValueOnce('test-refresh-token') + + // Remove identity URL sources but keep cookies available + delete globalThis.netlifyIdentityContext + globalThis.Netlify = { + context: { + cookies: mockCookies, + // no url property + }, + } as unknown as typeof globalThis.Netlify + + const { refreshSession } = await import('../src/refresh.js') + const { AuthError } = await import('../src/errors.js') + + await expect(refreshSession()).rejects.toThrow(AuthError) + }) +}) diff --git a/packages/identity/prod/test/stale-session.browser.test.ts b/packages/identity/prod/test/stale-session.browser.test.ts new file mode 100644 index 00000000..3a6dc9fc --- /dev/null +++ b/packages/identity/prod/test/stale-session.browser.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://localhost" } + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' +import { makeGoTrueUser } from './fixtures.js' + +const mockClearSession = vi.fn() +const mockCurrentUser = vi.fn() + +vi.mock('gotrue-js', () => ({ + default: class MockGoTrue { + currentUser = mockCurrentUser + }, +})) + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) +}) + +afterEach(() => { + resetTestGoTrueClient() + vi.resetAllMocks() + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) +}) + +describe('stale session detection (browser)', () => { + it('clears stale session and returns null when localStorage has user but nf_jwt cookie is gone', async () => { + const gotrueUser = { ...makeGoTrueUser(), clearSession: mockClearSession } + mockCurrentUser.mockReturnValue(gotrueUser) + + // No nf_jwt cookie (server logged us out) + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + + const { getUser } = await import('../src/user.js') + const user = await getUser() + + expect(user).toBeNull() + expect(mockClearSession).toHaveBeenCalledOnce() + }) + + it('returns user normally when localStorage has user and nf_jwt cookie exists', async () => { + const gotrueUser = { ...makeGoTrueUser(), clearSession: mockClearSession, tokenDetails: vi.fn() } + mockCurrentUser.mockReturnValue(gotrueUser) + + const header = btoa(JSON.stringify({ alg: 'HS256' })) + const payload = btoa(JSON.stringify({ sub: '550e8400-e29b-41d4-a716-446655440000' })) + Object.defineProperty(document, 'cookie', { writable: true, value: `nf_jwt=${header}.${payload}.sig` }) + + const { getUser } = await import('../src/user.js') + const user = await getUser() + + if (!user) throw new Error('expected user to not be null') + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(mockClearSession).not.toHaveBeenCalled() + }) + + it('returns null on subsequent calls after stale session is cleared', async () => { + const gotrueUser = { ...makeGoTrueUser(), clearSession: mockClearSession } + + // First call: gotrue has a user, no cookie + mockCurrentUser.mockReturnValue(gotrueUser) + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) + + const { getUser } = await import('../src/user.js') + expect(await getUser()).toBeNull() + expect(mockClearSession).toHaveBeenCalledOnce() + + // Second call: clearSession worked, gotrue returns null + mockCurrentUser.mockReturnValue(null) + expect(await getUser()).toBeNull() + }) +}) diff --git a/packages/identity/prod/test/user.browser.test.ts b/packages/identity/prod/test/user.browser.test.ts new file mode 100644 index 00000000..3b5b560e --- /dev/null +++ b/packages/identity/prod/test/user.browser.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://localhost" } + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { resetTestGoTrueClient } from '../src/environment.js' + +const mockCurrentUser = vi.fn().mockReturnValue(null) +const mockCreateUser = vi.fn() + +vi.mock('gotrue-js', () => ({ + default: class MockGoTrue { + currentUser = mockCurrentUser + createUser = mockCreateUser + }, +})) + +/** Builds a fake JWT (header.payload.signature) from a claims object. */ +const fakeJwt = (claims: Record): string => { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const payload = btoa(JSON.stringify(claims)) + return `${header}.${payload}.fake-signature` +} + +beforeEach(() => { + vi.resetModules() + resetTestGoTrueClient() + vi.resetAllMocks() + mockCurrentUser.mockReturnValue(null) + localStorage.clear() + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) +}) + +afterEach(() => { + resetTestGoTrueClient() + localStorage.clear() + Object.defineProperty(document, 'cookie', { writable: true, value: '' }) +}) + +describe('getUser (browser)', () => { + it('returns null when no session exists in localStorage', async () => { + const { getUser } = await import('../src/user.js') + expect(await getUser()).toBeNull() + }) + + it('returns user from nf_jwt cookie when no localStorage session', async () => { + const jwt = fakeJwt({ + sub: 'cookie-user-123', + email: 'cookie@example.com', + exp: Math.floor(Date.now() / 1000) + 3600, + app_metadata: { provider: 'email' }, + user_metadata: { full_name: 'Cookie User' }, + }) + + Object.defineProperty(document, 'cookie', { writable: true, value: `nf_jwt=${jwt}` }) + + // hydrateSession calls client.createUser then toUser + const mockGoTrueUser = { + id: 'cookie-user-123', + email: 'cookie@example.com', + app_metadata: { provider: 'email' }, + user_metadata: { full_name: 'Cookie User' }, + } + mockCreateUser.mockResolvedValue(mockGoTrueUser) + + const { getUser } = await import('../src/user.js') + const user = await getUser() + if (!user) throw new Error('expected user to not be null') + expect(user.id).toBe('cookie-user-123') + expect(user.email).toBe('cookie@example.com') + expect(user.provider).toBe('email') + expect(user.name).toBe('Cookie User') + }) + + it('returns null when nf_jwt cookie contains invalid JWT', async () => { + Object.defineProperty(document, 'cookie', { writable: true, value: 'nf_jwt=not-a-jwt' }) + + const { getUser } = await import('../src/user.js') + expect(await getUser()).toBeNull() + }) +}) + +describe('isAuthenticated (browser)', () => { + it('returns false when no session exists', async () => { + const { isAuthenticated } = await import('../src/user.js') + expect(await isAuthenticated()).toBe(false) + }) + + it('returns true when nf_jwt cookie is present', async () => { + const jwt = fakeJwt({ + sub: 'cookie-user-123', + email: 'cookie@example.com', + exp: Math.floor(Date.now() / 1000) + 3600, + app_metadata: { provider: 'email' }, + user_metadata: {}, + }) + + Object.defineProperty(document, 'cookie', { writable: true, value: `nf_jwt=${jwt}` }) + + const mockGoTrueUser = { + id: 'cookie-user-123', + email: 'cookie@example.com', + app_metadata: { provider: 'email' }, + user_metadata: {}, + } + mockCreateUser.mockResolvedValue(mockGoTrueUser) + + const { isAuthenticated } = await import('../src/user.js') + expect(await isAuthenticated()).toBe(true) + }) +}) diff --git a/packages/identity/prod/test/user.test.ts b/packages/identity/prod/test/user.test.ts new file mode 100644 index 00000000..46cb5bd7 --- /dev/null +++ b/packages/identity/prod/test/user.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { toUser, getUser, isAuthenticated, decodeJwtPayload } from '../src/user.js' +import { resetTestGoTrueClient } from '../src/environment.js' +import { makeGoTrueUser } from './fixtures.js' + +/** Builds a fake JWT (header.payload.signature) from a claims object. */ +const fakeJwt = (claims: Record): string => { + const header = btoa(JSON.stringify({ alg: 'HS256' })) + const payload = btoa(JSON.stringify(claims)) + return `${header}.${payload}.fake-sig` +} + +describe('toUser', () => { + it('normalizes a UserData to User', () => { + const goTrueUser = makeGoTrueUser() + const user = toUser(goTrueUser) + + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.email).toBe('jane@example.com') + expect(user.confirmedAt).toBe('2026-01-01T00:00:00Z') + expect(user.provider).toBe('github') + expect(user.name).toBe('Jane Doe') + expect(user.pictureUrl).toBe('https://example.com/avatar.png') + expect(user.createdAt).toBe('2026-01-01T00:00:00Z') + expect(user.updatedAt).toBe('2026-02-25T00:00:00Z') + expect(user.userMetadata).toEqual(goTrueUser.user_metadata) + }) + + it('maps GoTrue-level fields', () => { + const goTrueUser = makeGoTrueUser({ + aud: 'app-audience', + role: 'editor', + invited_at: '2026-01-15T00:00:00Z', + confirmation_sent_at: '2026-01-01T00:00:00Z', + recovery_sent_at: '2026-02-01T00:00:00Z', + new_email: 'new@example.com', + email_change_sent_at: '2026-02-20T00:00:00Z', + last_sign_in_at: '2026-02-25T00:00:00Z', + }) + const user = toUser(goTrueUser) + expect(user.role).toBe('editor') + expect(user.invitedAt).toBe('2026-01-15T00:00:00Z') + expect(user.confirmationSentAt).toBe('2026-01-01T00:00:00Z') + expect(user.recoverySentAt).toBe('2026-02-01T00:00:00Z') + expect(user.pendingEmail).toBe('new@example.com') + expect(user.emailChangeSentAt).toBe('2026-02-20T00:00:00Z') + expect(user.lastSignInAt).toBe('2026-02-25T00:00:00Z') + }) + + it('omits GoTrue-level fields when empty or absent', () => { + const goTrueUser = makeGoTrueUser() + const user = toUser(goTrueUser) + expect(user.role).toBeUndefined() + expect(user.invitedAt).toBeUndefined() + expect(user.confirmationSentAt).toBeUndefined() + expect(user.recoverySentAt).toBeUndefined() + expect(user.pendingEmail).toBeUndefined() + expect(user.emailChangeSentAt).toBeUndefined() + expect(user.lastSignInAt).toBeUndefined() + }) + + it('extracts roles from app_metadata', () => { + const goTrueUser = makeGoTrueUser({ + app_metadata: { provider: 'email', roles: ['admin', 'editor'] }, + }) + const user = toUser(goTrueUser) + expect(user.roles).toEqual(['admin', 'editor']) + }) + + it('handles missing roles', () => { + const goTrueUser = makeGoTrueUser() + const user = toUser(goTrueUser) + expect(user.roles).toBeUndefined() + }) + + it('handles missing optional fields', () => { + const goTrueUser = makeGoTrueUser({ + confirmed_at: null, + user_metadata: {}, + }) + const user = toUser(goTrueUser) + + expect(user.confirmedAt).toBeUndefined() + expect(user.name).toBeUndefined() + expect(user.pictureUrl).toBeUndefined() + }) + + it('handles undefined user_metadata and app_metadata', () => { + const goTrueUser = makeGoTrueUser({ + user_metadata: undefined as unknown as Record, + app_metadata: undefined as unknown as Record, + }) + const user = toUser(goTrueUser) + + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.provider).toBeUndefined() + expect(user.name).toBeUndefined() + expect(user.pictureUrl).toBeUndefined() + expect(user.userMetadata).toEqual({}) + }) +}) + +describe('getUser (server)', () => { + afterEach(() => { + delete globalThis.netlifyIdentityContext + resetTestGoTrueClient() + vi.restoreAllMocks() + }) + + it('returns null when no identity context exists', async () => { + expect(await getUser()).toBeNull() + }) + + it('returns User from identity context (claims fallback)', async () => { + // No identity URL available, so fetch can't happen; falls back to claims + globalThis.netlifyIdentityContext = { + user: { + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + exp: 9999999999, + app_metadata: { provider: 'github' }, + user_metadata: { full_name: 'Jane Doe' }, + }, + } + + const user = await getUser() + if (!user) throw new Error('expected user to not be null') + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.email).toBe('jane@example.com') + expect(user.provider).toBe('github') + expect(user.name).toBe('Jane Doe') + }) + + it('fetches full user from GoTrue when identity URL is available', async () => { + const token = fakeJwt({ sub: '550e8400-e29b-41d4-a716-446655440000', email: 'jane@example.com' }) + globalThis.netlifyIdentityContext = { + url: 'https://example.com/.netlify/identity', + token, + user: { sub: '550e8400-e29b-41d4-a716-446655440000', email: 'jane@example.com' }, + } + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + id: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + confirmed_at: '2026-01-01T00:00:00Z', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-02-25T00:00:00Z', + app_metadata: { provider: 'github', roles: ['admin'] }, + user_metadata: { full_name: 'Jane Doe', avatar_url: 'https://example.com/avatar.png' }, + }), + { status: 200 }, + ), + ) + + const user = await getUser() + if (!user) throw new Error('expected user to not be null') + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.confirmedAt).toBe('2026-01-01T00:00:00Z') + expect(user.createdAt).toBe('2026-01-01T00:00:00Z') + expect(user.pictureUrl).toBe('https://example.com/avatar.png') + expect(user.roles).toEqual(['admin']) + }) + + it('falls back to claims when GoTrue fetch fails', async () => { + const token = fakeJwt({ sub: '550e8400-e29b-41d4-a716-446655440000', email: 'jane@example.com' }) + globalThis.netlifyIdentityContext = { + url: 'https://example.com/.netlify/identity', + token, + user: { + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + app_metadata: { provider: 'email', roles: ['editor'] }, + user_metadata: { full_name: 'Jane Doe' }, + }, + } + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error')) + + const user = await getUser() + if (!user) throw new Error('expected user to not be null') + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.roles).toEqual(['editor']) + // Falls back to claims, so no GoTrue-level fields + expect(user.role).toBeUndefined() + expect(user.createdAt).toBeUndefined() + expect(user.lastSignInAt).toBeUndefined() + }) + + it('handles user with missing metadata fields', async () => { + globalThis.netlifyIdentityContext = { + user: { + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + }, + } + + const user = await getUser() + if (!user) throw new Error('expected user to not be null') + expect(user.id).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(user.email).toBe('jane@example.com') + expect(user.provider).toBeUndefined() + expect(user.name).toBeUndefined() + expect(user.userMetadata).toEqual({}) + }) +}) + +describe('getUser (server, cookie fallback)', () => { + const savedUrl = process.env.URL + + afterEach(() => { + if (savedUrl !== undefined) { + process.env.URL = savedUrl + } else { + delete process.env.URL + } + delete globalThis.netlifyIdentityContext + delete (globalThis as Record).Netlify + resetTestGoTrueClient() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('falls back to nf_jwt cookie when identityContext has no token', async () => { + delete process.env.URL + const claims = { + sub: 'cookie-user-456', + email: 'cookie@example.com', + app_metadata: { provider: 'email' }, + user_metadata: { full_name: 'Cookie User' }, + } + const jwt = fakeJwt(claims) + + globalThis.Netlify = { + context: { + cookies: { + get: (name: string) => (name === 'nf_jwt' ? jwt : undefined), + set: () => {}, + delete: () => {}, + }, + }, + } as unknown as typeof globalThis.Netlify + + const user = await getUser() + // Fail-closed: without server-validated identityContext, unverified cookies are not trusted + expect(user).toBeNull() + }) + + it('returns null when no identityContext and no cookies', async () => { + expect(await getUser()).toBeNull() + }) +}) + +describe('decodeJwtPayload', () => { + it('decodes a valid JWT payload', () => { + const claims = { sub: 'user-1', email: 'test@example.com' } + const jwt = `${btoa(JSON.stringify({ alg: 'HS256' }))}.${btoa(JSON.stringify(claims))}.sig` + + const result = decodeJwtPayload(jwt) + expect(result).toEqual(expect.objectContaining({ sub: 'user-1', email: 'test@example.com' })) + }) + + it('returns null for token with wrong number of segments', () => { + expect(decodeJwtPayload('only-one-part')).toBeNull() + expect(decodeJwtPayload('two.parts')).toBeNull() + expect(decodeJwtPayload('a.b.c.d')).toBeNull() + }) + + it('returns null for token with invalid base64 payload', () => { + expect(decodeJwtPayload('header.!!!invalid!!!.sig')).toBeNull() + }) + + it('returns null for token with non-JSON payload', () => { + const notJson = btoa('this is not json') + expect(decodeJwtPayload(`header.${notJson}.sig`)).toBeNull() + }) + + it('handles base64url-encoded payloads (- and _ characters)', () => { + const claims = { sub: 'user-1' } + const standard = btoa(JSON.stringify(claims)) + const urlSafe = standard.replace(/\+/g, '-').replace(/\//g, '_') + const jwt = `header.${urlSafe}.sig` + + const result = decodeJwtPayload(jwt) + expect(result).toEqual(expect.objectContaining({ sub: 'user-1' })) + }) +}) + +describe('isAuthenticated (server)', () => { + afterEach(() => { + delete globalThis.netlifyIdentityContext + resetTestGoTrueClient() + }) + + it('returns false when no session exists', async () => { + expect(await isAuthenticated()).toBe(false) + }) + + it('returns true when user is present', async () => { + globalThis.netlifyIdentityContext = { + user: { + sub: '550e8400-e29b-41d4-a716-446655440000', + email: 'jane@example.com', + app_metadata: { provider: 'github' }, + user_metadata: {}, + }, + } + expect(await isAuthenticated()).toBe(true) + }) +}) diff --git a/packages/identity/prod/tsconfig.json b/packages/identity/prod/tsconfig.json new file mode 100644 index 00000000..83e9a4b7 --- /dev/null +++ b/packages/identity/prod/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true, + "target": "ES2020", + "module": "nodenext", + "allowJs": true, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "outDir": "./dist", + "removeComments": false, + "strict": true, + "moduleResolution": "nodenext", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/packages/identity/prod/tsup.config.ts b/packages/identity/prod/tsup.config.ts new file mode 100644 index 00000000..689ec359 --- /dev/null +++ b/packages/identity/prod/tsup.config.ts @@ -0,0 +1,16 @@ +import { argv } from 'node:process' + +import { defineConfig } from 'tsup' + +export default defineConfig([ + { + clean: true, + entry: ['src/main.ts'], + tsconfig: 'tsconfig.json', + bundle: true, + format: ['cjs', 'esm'], + dts: true, + outDir: './dist', + watch: argv.includes('--watch'), + }, +]) diff --git a/packages/identity/prod/vitest.config.ts b/packages/identity/prod/vitest.config.ts new file mode 100644 index 00000000..6e8f57e5 --- /dev/null +++ b/packages/identity/prod/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + esbuild: { + target: 'esnext', + }, + test: { + include: ['test/**/*.test.ts'], + testTimeout: 30_000, + }, +}) diff --git a/release-please-config.json b/release-please-config.json index f8d2fca8..7c8e1117 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -31,6 +31,7 @@ "packages/functions/dev": {}, "packages/functions/prod": {}, "packages/headers": {}, + "packages/identity/prod": {}, "packages/images": {}, "packages/nuxt-module": {}, "packages/otel": {},