diff --git a/package.json b/package.json index 1b440c27..eb65a59e 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "lucide-react": "^0.577.0", "msw": "^2.12.2", "nanoid": "^5.1.6", - "next": "16.2.3", + "next": "16.2.6", "next-themes": "^0.4.6", "nuqs": "^2.8.1", "pg": "^8.13.3", @@ -116,18 +116,20 @@ "express-rate-limit": ">=8.2.2", "lodash": ">=4.17.23", "lodash-es": ">=4.17.23", - "hono": ">=4.12.14", + "hono": ">=4.12.18", "@hono/node-server": "^1.19.14", "defu": "^6.1.5", "rollup": ">=4.59.0", "undici": "7.24.6", - "kysely": ">=0.28.14", + "kysely": ">=0.28.17 <0.29.0", "picomatch": ">=4.0.4", "qs": ">=6.14.2", "yaml": ">=2.8.3", "path-to-regexp@>=8.0.0 <8.4.0": "8.4.0", "path-to-regexp@>=0.1.0 <0.1.13": "0.1.13", - "postcss": ">=8.5.10" + "postcss": ">=8.5.10", + "fast-uri": ">=3.1.2", + "ip-address": ">=10.1.1" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf4109b0..06d8e88b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,20 @@ overrides: express-rate-limit: '>=8.2.2' lodash: '>=4.17.23' lodash-es: '>=4.17.23' - hono: '>=4.12.14' + hono: '>=4.12.18' '@hono/node-server': ^1.19.14 defu: ^6.1.5 rollup: '>=4.59.0' undici: 7.24.6 - kysely: '>=0.28.14' + kysely: '>=0.28.17 <0.29.0' picomatch: '>=4.0.4' qs: '>=6.14.2' yaml: '>=2.8.3' path-to-regexp@>=8.0.0 <8.4.0: 8.4.0 path-to-regexp@>=0.1.0 <0.1.13: 0.1.13 postcss: '>=8.5.10' + fast-uri: '>=3.1.2' + ip-address: '>=10.1.1' importers: @@ -99,7 +101,7 @@ importers: version: 3.0.1(ajv@8.18.0) better-auth: specifier: 1.6.2 - version: 1.6.2(@opentelemetry/api@1.9.0)(mongodb@7.1.0)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(msw@2.12.13(@types/node@24.12.0)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 1.6.2(@opentelemetry/api@1.9.0)(mongodb@7.1.0)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(msw@2.12.13(@types/node@24.12.0)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -122,14 +124,14 @@ importers: specifier: ^5.1.6 version: 5.1.7 next: - specifier: 16.2.3 - version: 16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.6 + version: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) nuqs: specifier: ^2.8.1 - version: 2.8.9(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 2.8.9(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) pg: specifier: ^8.13.3 version: 8.20.0 @@ -342,7 +344,7 @@ packages: '@opentelemetry/api': ^1.9.0 better-call: 1.3.5 jose: ^6.1.0 - kysely: '>=0.28.14' + kysely: '>=0.28.17 <0.29.0' nanostores: ^1.0.1 peerDependenciesMeta: '@cloudflare/workers-types': @@ -363,7 +365,7 @@ packages: peerDependencies: '@better-auth/core': ^1.6.2 '@better-auth/utils': 0.4.0 - kysely: '>=0.28.14' + kysely: '>=0.28.17 <0.29.0' peerDependenciesMeta: kysely: optional: true @@ -510,9 +512,6 @@ packages: '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -728,7 +727,7 @@ packages: resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: '>=4.12.14' + hono: '>=4.12.18' '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} @@ -988,57 +987,57 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.3': - resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/swc-darwin-arm64@16.2.3': - resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.3': - resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.3': - resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.3': - resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.3': - resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.3': - resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.3': - resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.3': - resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2545,8 +2544,8 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -2679,8 +2678,8 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - hono@4.12.16: - resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==} + hono@4.12.21: + resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -2731,8 +2730,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2885,8 +2884,8 @@ packages: resolution: {integrity: sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==} engines: {node: '>= 18'} - kysely@0.28.14: - resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==} + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} lightningcss-android-arm64@1.31.1: @@ -3359,8 +3358,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.3: - resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -4403,7 +4402,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1)': + '@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -4412,42 +4411,42 @@ snapshots: '@standard-schema/spec': 1.1.0 better-call: 1.3.5(zod@4.3.6) jose: 6.2.0 - kysely: 0.28.14 + kysely: 0.28.17 nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)': + '@better-auth/drizzle-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 - '@better-auth/kysely-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.14)': + '@better-auth/kysely-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 optionalDependencies: - kysely: 0.28.14 + kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)': + '@better-auth/memory-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 - '@better-auth/mongo-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(mongodb@7.1.0)': + '@better-auth/mongo-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(mongodb@7.1.0)': dependencies: - '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 optionalDependencies: mongodb: 7.1.0 - '@better-auth/prisma-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)': + '@better-auth/prisma-adapter@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)': dependencies: - '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 - '@better-auth/telemetry@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + '@better-auth/telemetry@1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: - '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -4526,11 +4525,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 @@ -4689,9 +4683,9 @@ snapshots: '@hey-api/types@0.1.4': {} - '@hono/node-server@1.19.14(hono@4.12.16)': + '@hono/node-server@1.19.14(hono@4.12.21)': dependencies: - hono: 4.12.16 + hono: 4.12.21 '@img/colour@1.0.0': optional: true @@ -4778,7 +4772,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.9.2 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -4863,7 +4857,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.16) + '@hono/node-server': 1.19.14(hono@4.12.21) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4873,7 +4867,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.16 + hono: 4.12.21 jose: 6.2.0 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4912,30 +4906,30 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.3': {} + '@next/env@16.2.6': {} - '@next/swc-darwin-arm64@16.2.3': + '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@16.2.3': + '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@16.2.3': + '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@16.2.3': + '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@16.2.3': + '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@16.2.3': + '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@16.2.3': + '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@16.2.3': + '@next/swc-win32-x64-msvc@16.2.6': optional: true '@noble/ciphers@2.1.1': {} @@ -5852,7 +5846,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5908,15 +5902,15 @@ snapshots: baseline-browser-mapping@2.10.7: {} - better-auth@1.6.2(@opentelemetry/api@1.9.0)(mongodb@7.1.0)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(msw@2.12.13(@types/node@24.12.0)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))): + better-auth@1.6.2(@opentelemetry/api@1.9.0)(mongodb@7.1.0)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(msw@2.12.13(@types/node@24.12.0)(typescript@6.0.2))(vite@8.0.8(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))): dependencies: - '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0) - '@better-auth/kysely-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.14) - '@better-auth/memory-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(mongodb@7.1.0) - '@better-auth/prisma-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0) - '@better-auth/telemetry': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0) + '@better-auth/kysely-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(mongodb@7.1.0) + '@better-auth/prisma-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.0)(kysely@0.28.17)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -5924,12 +5918,12 @@ snapshots: better-call: 1.3.5(zod@4.3.6) defu: 6.1.7 jose: 6.2.0 - kysely: 0.28.14 + kysely: 0.28.17 nanostores: 1.1.1 zod: 4.3.6 optionalDependencies: mongodb: 7.1.0 - next: 16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: 8.20.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -6305,7 +6299,7 @@ snapshots: express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.1.0 + ip-address: 10.2.0 express@4.22.1: dependencies: @@ -6382,7 +6376,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: @@ -6579,7 +6573,7 @@ snapshots: headers-polyfill@4.0.3: {} - hono@4.12.16: {} + hono@4.12.21: {} html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: @@ -6630,7 +6624,7 @@ snapshots: inline-style-parser@0.2.7: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -6789,7 +6783,7 @@ snapshots: type-is: 2.0.1 vary: 1.1.2 - kysely@0.28.14: {} + kysely@0.28.17: {} lightningcss-android-arm64@1.31.1: optional: true @@ -7420,9 +7414,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.3 + '@next/env': 16.2.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.7 caniuse-lite: 1.0.30001778 @@ -7431,14 +7425,14 @@ snapshots: react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.3 - '@next/swc-darwin-x64': 16.2.3 - '@next/swc-linux-arm64-gnu': 16.2.3 - '@next/swc-linux-arm64-musl': 16.2.3 - '@next/swc-linux-x64-gnu': 16.2.3 - '@next/swc-linux-x64-musl': 16.2.3 - '@next/swc-win32-arm64-msvc': 16.2.3 - '@next/swc-win32-x64-msvc': 16.2.3 + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.58.2 babel-plugin-react-compiler: 1.0.0 @@ -7449,12 +7443,12 @@ snapshots: node-fetch-native@1.6.7: {} - nuqs@2.8.9(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + nuqs@2.8.9(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.4 optionalDependencies: - next: 16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) nypm@0.6.4: dependencies: diff --git a/src/app/api/auth/token-refresh/route.test.ts b/src/app/api/auth/token-refresh/route.test.ts index 4fb494b4..3db81dd7 100644 --- a/src/app/api/auth/token-refresh/route.test.ts +++ b/src/app/api/auth/token-refresh/route.test.ts @@ -1,8 +1,9 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { GET } from "./route"; +import { GET, POST } from "./route"; -const mockGetAccessToken = vi.hoisted(() => vi.fn()); +const mockRefreshToken = vi.hoisted(() => vi.fn()); +const mockSignOut = vi.hoisted(() => vi.fn()); const mockHeaders = vi.hoisted(() => vi.fn()); vi.mock("next/headers", () => ({ @@ -11,7 +12,10 @@ vi.mock("next/headers", () => ({ vi.mock("@/lib/auth/auth", () => ({ auth: { - api: { getAccessToken: mockGetAccessToken }, + api: { + refreshToken: mockRefreshToken, + signOut: mockSignOut, + }, }, })); @@ -21,31 +25,42 @@ function makeRequest(path = INTERNAL_URL) { return new NextRequest(path); } -function mockTokenSuccess(cookies: string[] = []) { - mockGetAccessToken.mockResolvedValue({ +function mockRefreshSuccess(cookies: string[] = []) { + mockRefreshToken.mockResolvedValue({ ok: true, status: 200, headers: { getSetCookie: () => cookies }, }); } -function mockTokenFailure(status = 401) { - mockGetAccessToken.mockResolvedValue({ +function mockRefreshFailure(status = 400) { + mockRefreshToken.mockResolvedValue({ ok: false, status, headers: { getSetCookie: () => [] }, }); } +function mockSignOutSuccess(cookies: string[] = []) { + mockSignOut.mockResolvedValue({ + ok: true, + status: 200, + headers: { getSetCookie: () => cookies }, + }); +} + describe("GET /api/auth/token-refresh", () => { beforeEach(() => { vi.clearAllMocks(); mockHeaders.mockResolvedValue(new Headers()); + // Default: signOut succeeds with no cookies (covers tests that don't care + // about the cleanup path). + mockSignOutSuccess(); }); describe("redirect URL uses BASE_URL, not request.url", () => { it("redirects to BASE_URL/catalog on success (not 0.0.0.0)", async () => { - mockTokenSuccess(); + mockRefreshSuccess(); const response = await GET( makeRequest(`${INTERNAL_URL}?redirect=%2Fcatalog`), ); @@ -56,7 +71,7 @@ describe("GET /api/auth/token-refresh", () => { it("redirects to BASE_URL/signin on token refresh failure", async () => { vi.spyOn(console, "warn").mockImplementation(() => {}); - mockTokenFailure(); + mockRefreshFailure(); const response = await GET(makeRequest(INTERNAL_URL)); expect(response.headers.get("location")).toBe( "http://localhost:3000/signin", @@ -65,7 +80,7 @@ describe("GET /api/auth/token-refresh", () => { it("redirects to BASE_URL/signin on unexpected error", async () => { vi.spyOn(console, "error").mockImplementation(() => {}); - mockGetAccessToken.mockRejectedValue(new Error("Network error")); + mockRefreshToken.mockRejectedValue(new Error("Network error")); const response = await GET(makeRequest(INTERNAL_URL)); expect(response.headers.get("location")).toBe( "http://localhost:3000/signin", @@ -75,7 +90,7 @@ describe("GET /api/auth/token-refresh", () => { describe("open redirect protection", () => { it("falls back to /catalog when no redirect param", async () => { - mockTokenSuccess(); + mockRefreshSuccess(); const response = await GET(makeRequest(INTERNAL_URL)); expect(response.headers.get("location")).toBe( "http://localhost:3000/catalog", @@ -83,7 +98,7 @@ describe("GET /api/auth/token-refresh", () => { }); it("falls back to /catalog for redirect starting with //", async () => { - mockTokenSuccess(); + mockRefreshSuccess(); const response = await GET( makeRequest(`${INTERNAL_URL}?redirect=//evil.com`), ); @@ -93,7 +108,7 @@ describe("GET /api/auth/token-refresh", () => { }); it("falls back to /catalog for redirect to external origin", async () => { - mockTokenSuccess(); + mockRefreshSuccess(); const response = await GET( makeRequest( `${INTERNAL_URL}?redirect=${encodeURIComponent("https://evil.com/phishing")}`, @@ -105,7 +120,7 @@ describe("GET /api/auth/token-refresh", () => { }); it("accepts valid internal path with query string", async () => { - mockTokenSuccess(); + mockRefreshSuccess(); const response = await GET( makeRequest( `${INTERNAL_URL}?redirect=${encodeURIComponent("/catalog?page=2")}`, @@ -121,7 +136,7 @@ describe("GET /api/auth/token-refresh", () => { it("copies Set-Cookie headers from Better Auth response onto the redirect", async () => { const cookie = "__Secure-better-auth.account_data=newvalue; Path=/; HttpOnly; SameSite=Lax"; - mockTokenSuccess([cookie]); + mockRefreshSuccess([cookie]); const response = await GET( makeRequest(`${INTERNAL_URL}?redirect=%2Fcatalog`), ); @@ -133,7 +148,7 @@ describe("GET /api/auth/token-refresh", () => { "__Secure-better-auth.account_data=val1; Path=/; HttpOnly", "__Secure-better-auth.session_token=val2; Path=/; HttpOnly", ]; - mockTokenSuccess(cookies); + mockRefreshSuccess(cookies); const response = await GET( makeRequest(`${INTERNAL_URL}?redirect=%2Fcatalog`), ); @@ -143,4 +158,96 @@ describe("GET /api/auth/token-refresh", () => { expect(setCookieHeader[1]).toBe(cookies[1]); }); }); + + describe("cookie cleanup on failure", () => { + it("forwards signOut Set-Cookie headers when refresh fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + mockRefreshFailure(); + const signOutCookies = [ + "__Secure-better-auth.session_token=; Path=/; Max-Age=0", + "__Secure-better-auth.account_data=; Path=/; Max-Age=0", + ]; + mockSignOutSuccess(signOutCookies); + + const response = await GET(makeRequest(INTERNAL_URL)); + + expect(response.headers.get("location")).toBe( + "http://localhost:3000/signin", + ); + const setCookieHeader = response.headers.getSetCookie(); + expect(setCookieHeader).toHaveLength(2); + expect(setCookieHeader).toEqual(signOutCookies); + }); + + it("forwards signOut Set-Cookie headers when refresh throws", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + mockRefreshToken.mockRejectedValue(new Error("Network error")); + const signOutCookies = [ + "__Secure-better-auth.session_token=; Path=/; Max-Age=0", + ]; + mockSignOutSuccess(signOutCookies); + + const response = await GET(makeRequest(INTERNAL_URL)); + + expect(response.headers.get("location")).toBe( + "http://localhost:3000/signin", + ); + expect(response.headers.get("set-cookie")).toBe(signOutCookies[0]); + }); + + it("still redirects to /signin when signOut itself fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + mockRefreshFailure(); + mockSignOut.mockRejectedValue(new Error("signOut boom")); + + const response = await GET(makeRequest(INTERNAL_URL)); + + expect(response.headers.get("location")).toBe( + "http://localhost:3000/signin", + ); + }); + + it("calls refreshToken with the configured providerId", async () => { + mockRefreshSuccess(); + await GET(makeRequest(`${INTERNAL_URL}?redirect=%2Fcatalog`)); + expect(mockRefreshToken).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ providerId: expect.any(String) }), + asResponse: true, + }), + ); + }); + }); + + describe("HTTP method parity", () => { + it("POST handler behaves identically to GET (no 405 on Server Action 307 redirects)", async () => { + mockRefreshSuccess(); + const response = await POST( + makeRequest(`${INTERNAL_URL}?redirect=%2Fcatalog`), + ); + expect(response.headers.get("location")).toBe( + "http://localhost:3000/catalog", + ); + }); + }); + + describe("redirect uses 303 See Other (forces GET on follow-up)", () => { + it("success redirect to safeRedirect is 303", async () => { + mockRefreshSuccess(); + const response = await GET( + makeRequest(`${INTERNAL_URL}?redirect=%2Fcatalog`), + ); + // 303 prevents a POST that arrived at this route (via 307 from a Server + // Action) from being replayed as a POST against /catalog. + expect(response.status).toBe(303); + }); + + it("failure redirect to /signin is 303", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + mockRefreshFailure(); + const response = await GET(makeRequest(INTERNAL_URL)); + expect(response.status).toBe(303); + }); + }); }); diff --git a/src/app/api/auth/token-refresh/route.ts b/src/app/api/auth/token-refresh/route.ts index 97ad3c97..a956cf93 100644 --- a/src/app/api/auth/token-refresh/route.ts +++ b/src/app/api/auth/token-refresh/route.ts @@ -12,16 +12,29 @@ const BASE_ORIGIN = new URL(BASE_URL).origin; * the rotated refresh token that Better Auth places in Set-Cookie headers * after a token refresh. This Route Handler acts as a proxy: * - * 1. Calls `auth.api.getAccessToken({ asResponse: true })` to trigger the refresh. - * 2. Copies the resulting Set-Cookie headers directly onto an HTTP redirect response. - * (Route Handlers CAN set cookies via the HTTP response, unlike Server Components.) + * 1. Calls `auth.api.refreshToken({ asResponse: true })` to FORCE a refresh. + * Unlike `getAccessToken`, this bypasses Better Auth's internal 5s threshold, + * which is what previously caused a redirect loop when the caller's near-expiry + * margin (e.g. 10s) was wider than Better Auth's refresh window: the route + * would be re-entered but no refresh (and no Set-Cookie) would occur. + * 2. Copies the resulting Set-Cookie headers onto the HTTP redirect response so + * the browser stores the rotated refresh token. (Route Handlers CAN set cookies + * via the HTTP response, unlike Server Components.) * 3. Redirects the browser back to the original page. * - * The browser follows the redirect, the `Set-Cookie` headers update the - * `account_data` cookie with the rotated refresh token (R2), and the page - * renders with a fresh, valid token. + * If the refresh fails (e.g. the refresh token has been revoked at the provider), + * the handler signs the user out via `auth.api.signOut` — which clears both + * `session_token` and `account_data` cookies — and redirects to /signin. Without + * this cleanup, the stale `account_data` cookie keeps `isTokenNearExpiry` returning + * true on every subsequent request, trapping the user in a refresh loop. + * + * Both GET and POST are exported: Next.js `redirect()` outside a Server Action uses + * 307 (method-preserving), so a redirect triggered from a Server Component render + * that follows a Server Action POST reaches this route as a POST. For the same + * reason, the outbound redirects below use 303 See Other (forces the browser to + * follow with GET) — a default 307 would re-POST `/catalog` or `/signin` and 405. */ -export async function GET(request: NextRequest) { +async function handler(request: NextRequest) { // Validate redirect target to prevent open redirects. // Parse with new URL() and enforce same-origin; extract only pathname+search+hash. const redirectParam = request.nextUrl.searchParams.get("redirect"); @@ -40,47 +53,73 @@ export async function GET(request: NextRequest) { const requestHeaders = await headers(); try { - const tokenResponse = await auth.api.getAccessToken({ + const tokenResponse = await auth.api.refreshToken({ headers: requestHeaders, body: { providerId: OIDC_PROVIDER_ID }, asResponse: true, }); if (!tokenResponse.ok) { - console.warn( - "[TokenRefresh] getAccessToken failed:", - tokenResponse.status, - ); - return NextResponse.redirect(new URL("/signin", BASE_URL)); + console.warn("[TokenRefresh] refreshToken failed:", tokenResponse.status); + return await signOutAndRedirect(requestHeaders); } const redirectResponse = NextResponse.redirect( new URL(safeRedirect, BASE_URL), + { status: 303 }, ); // Copy Set-Cookie headers from Better Auth's internal response directly // onto the HTTP redirect response. This is the correct mechanism to propagate // the rotated refresh token (R2) to the browser cookie — Route Handlers can // write cookies via the HTTP response, unlike Server Components. - const betterAuthHeaders = - tokenResponse.headers as typeof tokenResponse.headers & { - getSetCookie?: () => string[]; - }; - const setCookieHeaders = - typeof betterAuthHeaders.getSetCookie === "function" - ? betterAuthHeaders.getSetCookie() - : (() => { - const raw = tokenResponse.headers.get("set-cookie"); - return raw ? [raw] : []; - })(); - - for (const cookie of setCookieHeaders) { + for (const cookie of extractSetCookies(tokenResponse)) { redirectResponse.headers.append("set-cookie", cookie); } return redirectResponse; } catch (err) { console.error("[TokenRefresh] Unexpected error:", err); - return NextResponse.redirect(new URL("/signin", BASE_URL)); + return await signOutAndRedirect(requestHeaders); } } + +/** + * Signs the user out (clearing session + account_data cookies via Better Auth) + * and redirects to /signin. Used when the refresh attempt fails irrecoverably. + */ +async function signOutAndRedirect( + requestHeaders: Headers, +): Promise { + const response = NextResponse.redirect(new URL("/signin", BASE_URL), { + status: 303, + }); + try { + const signOutResponse = await auth.api.signOut({ + headers: requestHeaders, + asResponse: true, + }); + for (const cookie of extractSetCookies(signOutResponse)) { + response.headers.append("set-cookie", cookie); + } + } catch (err) { + // signOut should not fail in practice — it clears cookies even with no + // active session. Log so we can spot it, but still redirect to /signin. + console.error("[TokenRefresh] signOut failed during cleanup:", err); + } + return response; +} + +function extractSetCookies(response: Response): string[] { + const headers = response.headers as Response["headers"] & { + getSetCookie?: () => string[]; + }; + if (typeof headers.getSetCookie === "function") { + return headers.getSetCookie(); + } + const raw = response.headers.get("set-cookie"); + return raw ? [raw] : []; +} + +export const GET = handler; +export const POST = handler;