Skip to content

Commit

Permalink
Merge pull request #91 from manchenkoff/88-reduce-csrf-calls
Browse files Browse the repository at this point in the history
Reduce CSRF calls and support refreshing on SSR side
  • Loading branch information
manchenkoff authored May 12, 2024
2 parents 84fac56 + 8b45e06 commit 2993c9e
Show file tree
Hide file tree
Showing 4 changed files with 73 additions and 37 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -63,4 +65,4 @@
"vue-tsc": "^1.8.26"
},
"packageManager": "[email protected]"
}
}
87 changes: 51 additions & 36 deletions src/runtime/httpFactory.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void>}
*/
async function buildClientHeaders(headers: Headers): Promise<HeadersInit> {
async function initCsrfCookie(): Promise<void> {
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<HeadersInit>}
*/
async function useCsrfHeader(headers: Headers): Promise<HeadersInit> {
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 }),
};
}

Expand All @@ -78,7 +93,7 @@ export function createHttpClient(logger: ConsolaInstance): $Fetch {
redirect: 'manual',
retry: options.client.retry,

async onRequest({ request, options }): Promise<void> {
async onRequest({ options }): Promise<void> {
const method = options.method?.toLowerCase() ?? 'get';

options.headers = {
Expand All @@ -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');
}
Expand All @@ -96,38 +111,38 @@ 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);
}
},

async onResponse({ request, response }): Promise<void> {
// 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
Expand Down
1 change: 1 addition & 0 deletions src/runtime/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 2993c9e

Please sign in to comment.