Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce CSRF calls and support refreshing on SSR side #91

Merged
merged 3 commits into from
May 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading