diff --git a/package.json b/package.json index 227024d..ae83fd9 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@nuxt/schema": "^3.9.0", "@nuxt/test-utils": "^3.9.0", "@types/node": "^20.11.13", + "@types/set-cookie-parser": "^2.4.7", "changelogen": "^0.5.5", "eslint": "^8.56.0", "eslint-config-prettier": "^9.0.0", @@ -55,6 +56,7 @@ "nuxi": "^3.10.0", "nuxt": "^3.10.0", "prettier": "^3.0.3", + "set-cookie-parser": "^2.6.0", "typescript": "^5.2.2", "vite": "^4.4.9", "vitest": "^1.2.2", @@ -63,4 +65,4 @@ "vue-tsc": "^1.8.26" }, "packageManager": "yarn@4.2.1" -} \ No newline at end of file +} diff --git a/src/runtime/httpFactory.ts b/src/runtime/httpFactory.ts index 349c074..9f2abfb 100644 --- a/src/runtime/httpFactory.ts +++ b/src/runtime/httpFactory.ts @@ -1,4 +1,9 @@ import type { $Fetch, FetchOptions } from 'ofetch'; +import { appendResponseHeader } from 'h3'; +import { + splitCookiesString, + parseString as parseCookieString, +} from 'set-cookie-parser'; import { useCookie, useRequestEvent, @@ -18,57 +23,67 @@ const COOKIE_OPTIONS: { readonly: true } = { readonly: true }; export function createHttpClient(logger: ConsolaInstance): $Fetch { const options = useSanctumConfig(); - const event = useRequestEvent(); const user = useSanctumUser(); const nuxtApp = useNuxtApp(); + const event = useRequestEvent(nuxtApp); /** - * Request a new CSRF cookie from the API and pass it to the headers collection - * @param headers Headers collection to extend - * @returns {HeadersInit} + * Request a new CSRF cookie from the API + * @returns {Promise} */ - async function buildClientHeaders(headers: Headers): Promise { + async function initCsrfCookie(): Promise { await $fetch(options.endpoints.csrf, { baseURL: options.baseUrl, credentials: 'include', }); - const csrfToken = useCookie(options.csrf.cookie, COOKIE_OPTIONS).value; + logger.debug('CSRF cookie has been initialized'); + } + + /** + * Add CSRF token to the headers collection to pass from the client to the API + * @param headers Headers collection to extend + * @returns {Promise} + */ + async function useCsrfHeader(headers: Headers): Promise { + let csrfToken = useCookie(options.csrf.cookie, COOKIE_OPTIONS); - if (!csrfToken) { + if (!csrfToken.value) { + await initCsrfCookie(); + + csrfToken = useCookie(options.csrf.cookie, COOKIE_OPTIONS); + } + + if (!csrfToken.value) { logger.warn( - 'CSRF cookie is missing in response, check your API configuration' + `${options.csrf.cookie} cookie is missing, unable to set ${options.csrf.header} header` ); + + return headers as HeadersInit; } + logger.debug(`Added ${options.csrf.header} header to pass to the API`); + return { ...headers, - ...(csrfToken && { [options.csrf.header]: csrfToken }), + ...(csrfToken.value && { [options.csrf.header]: csrfToken.value }), }; } /** * Pass all cookies, headers and referrer from the client to the API * @param headers Headers collection to extend - * @returns { HeadersInit } + * @returns {HeadersInit} */ function buildServerHeaders(headers: Headers): HeadersInit { - const csrfToken = useCookie(options.csrf.cookie, COOKIE_OPTIONS).value; const clientCookies = useRequestHeaders(['cookie']); const origin = options.origin ?? useRequestURL().origin; - if (!csrfToken) { - logger.warn( - `Unable to set ${options.csrf.header} header, CSRF cookie is missing` - ); - } - return { ...headers, Referer: origin, Origin: origin, ...(clientCookies.cookie && clientCookies), - ...(csrfToken && { [options.csrf.header]: csrfToken }), }; } @@ -78,7 +93,7 @@ export function createHttpClient(logger: ConsolaInstance): $Fetch { redirect: 'manual', retry: options.client.retry, - async onRequest({ request, options }): Promise { + async onRequest({ options }): Promise { const method = options.method?.toLowerCase() ?? 'get'; options.headers = { @@ -87,7 +102,7 @@ export function createHttpClient(logger: ConsolaInstance): $Fetch { }; // https://laravel.com/docs/10.x/routing#form-method-spoofing - if (options.body instanceof FormData && method === 'put') { + if (method === 'put' && options.body instanceof FormData) { options.method = 'POST'; options.body.append('_method', 'PUT'); } @@ -96,16 +111,8 @@ export function createHttpClient(logger: ConsolaInstance): $Fetch { options.headers = buildServerHeaders(options.headers); } - if (import.meta.client) { - if (!SECURE_METHODS.has(method)) { - logger.debug( - `Skipping CSRF token header for safe method [${request}]` - ); - - return; - } - - options.headers = await buildClientHeaders(options.headers); + if (SECURE_METHODS.has(method)) { + options.headers = await useCsrfHeader(options.headers); } }, @@ -113,21 +120,29 @@ export function createHttpClient(logger: ConsolaInstance): $Fetch { // pass all cookies from the API to the client on SSR response if (import.meta.server) { const serverCookieName = 'set-cookie'; - const cookie = response.headers.get(serverCookieName); + const cookieHeader = response.headers.get(serverCookieName); - if (cookie === null || event === undefined) { + if (cookieHeader === null || event === undefined) { logger.debug( `No cookies to pass to the client [${request}]` ); + return; } + const cookies = splitCookiesString(cookieHeader); + const cookieNameList = []; + + for (const cookie of cookies) { + appendResponseHeader(event, serverCookieName, cookie); + + const metadata = parseCookieString(cookie); + cookieNameList.push(metadata.name); + } + logger.debug( - `Passing API cookies from Nuxt server to the client response [${request}]`, - cookie + `Append API cookies from SSR to CSR response [${cookieNameList.join(', ')}]` ); - - event.headers.append(serverCookieName, cookie); } // follow redirects on client diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index c7c96a6..e253ad6 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -44,6 +44,7 @@ export default defineNuxtPlugin(async () => { identityFetchedOnInit.value = true; try { + logger.debug('Fetching user identity on plugin initialization'); user.value = await client(options.endpoints.user); } catch (error) { handleIdentityLoadError(error as Error, logger); diff --git a/yarn.lock b/yarn.lock index e31bf8c..86b0ca9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2108,6 +2108,15 @@ __metadata: languageName: node linkType: hard +"@types/set-cookie-parser@npm:^2.4.7": + version: 2.4.7 + resolution: "@types/set-cookie-parser@npm:2.4.7" + dependencies: + "@types/node": "npm:*" + checksum: 10/01ef803e24b8cd33e49fe7463f32a562da45ce3f960381b90cccf67ea71b1830d2273df044255b040069c0a92ea25b4bf21c39ac2f85b50c01818ded5e918554 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^6.5.0": version: 6.20.0 resolution: "@typescript-eslint/eslint-plugin@npm:6.20.0" @@ -6504,6 +6513,7 @@ __metadata: "@nuxt/schema": "npm:^3.9.0" "@nuxt/test-utils": "npm:^3.9.0" "@types/node": "npm:^20.11.13" + "@types/set-cookie-parser": "npm:^2.4.7" changelogen: "npm:^0.5.5" defu: "npm:^6.1.4" eslint: "npm:^8.56.0" @@ -6513,6 +6523,7 @@ __metadata: nuxi: "npm:^3.10.0" nuxt: "npm:^3.10.0" prettier: "npm:^3.0.3" + set-cookie-parser: "npm:^2.6.0" typescript: "npm:^5.2.2" vite: "npm:^4.4.9" vitest: "npm:^1.2.2" @@ -7840,6 +7851,13 @@ __metadata: languageName: node linkType: hard +"set-cookie-parser@npm:^2.6.0": + version: 2.6.0 + resolution: "set-cookie-parser@npm:2.6.0" + checksum: 10/8d451ebadb760989f93b634942c79de3c925ca7a986d133d08a80c40b5ae713ce12e354f0d5245c49f288c52daa7bd6554d5dc52f8a4eecaaf5e192881cf2b1f + languageName: node + linkType: hard + "setprototypeof@npm:1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0"