Skip to content

Commit 296075f

Browse files
committed
feat: fix ssr cookie handling in all edge cases
1 parent 627c57c commit 296075f

File tree

8 files changed

+813
-292
lines changed

8 files changed

+813
-292
lines changed

packages/ssr/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@
3535
},
3636
"homepage": "https://github.com/supabase/auth-helpers#readme",
3737
"dependencies": {
38-
"cookie": "^0.5.0",
39-
"ramda": "^0.29.0"
38+
"cookie": "^0.5.0"
4039
},
4140
"devDependencies": {
4241
"@supabase/supabase-js": "2.42.0",

packages/ssr/src/common.ts

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { parse, serialize } from 'cookie';
2+
3+
import {
4+
DEFAULT_COOKIE_OPTIONS,
5+
combineChunks,
6+
createChunks,
7+
deleteChunks,
8+
isBrowser,
9+
isChunkLike
10+
} from './utils';
11+
12+
import type {
13+
CookieMethods,
14+
CookieMethodsBrowser,
15+
CookieOptions,
16+
CookieOptionsWithName,
17+
GetAllCookies,
18+
SetAllCookies
19+
} from './types';
20+
21+
/**
22+
* Creates a storage client that handles cookies correctly for browser and
23+
* server clients with or without properly provided cookie methods.
24+
*/
25+
export function createStorageFromOptions(
26+
options: {
27+
cookies?: CookieMethods | CookieMethodsBrowser;
28+
cookieOptions?: CookieOptionsWithName;
29+
} | null,
30+
isServerClient: boolean
31+
) {
32+
const cookies = options?.cookies ?? null;
33+
34+
const setItems: { [key: string]: string } = {};
35+
const removedItems: { [key: string]: boolean } = {};
36+
37+
let getAll: (keyHints: string[]) => ReturnType<GetAllCookies>;
38+
let setAll: SetAllCookies;
39+
40+
if (cookies) {
41+
if ('get' in cookies) {
42+
// Just get is not enough, because the client needs to see what cookies are already set and unset them if necessary. To attempt to fix this behavior for most use cases, we pass "hints" which is the keys of the storage items. They are then converted to their corresponding cookie chunk names and are fetched with get. Only 5 chunks are fetched, which should be enough for the majority of use cases, but does not solve those with very large sessions.
43+
const getWithHints = async (keyHints: string[]) => {
44+
// optimistically find the first 5 potential chunks for the specified key
45+
const chunkNames = keyHints.flatMap((keyHint) => [
46+
keyHint,
47+
...Array.from({ length: 5 }).map((i) => `${keyHint}.${i}`)
48+
]);
49+
50+
const chunks: ReturnType<GetAllCookies> = [];
51+
52+
for (let i = 0; i < chunkNames.length; i += 1) {
53+
const value = await cookies.get(chunkNames[i]);
54+
55+
if (!value && typeof value !== 'string') {
56+
continue;
57+
}
58+
59+
chunks.push({ name: chunkNames[i], value });
60+
}
61+
62+
// TODO: detect and log stale chunks error
63+
64+
return chunks;
65+
};
66+
67+
getAll = async (keyHints: string[]) => await getWithHints(keyHints);
68+
69+
if ('set' in cookies && 'remove' in cookies) {
70+
setAll = async (setCookies) => {
71+
for (let i = 0; i < setCookies.length; i += 1) {
72+
const { name, value, options } = setCookies[i];
73+
74+
if (value) {
75+
await cookies.set(name, value, options);
76+
} else {
77+
await cookies.remove(name);
78+
}
79+
}
80+
};
81+
} else if (isServerClient) {
82+
setAll = async () => {
83+
console.warn(
84+
'@supabase/ssr: createServerClient was configured without set and remove cookie methods, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness. Consider switching to the getAll and setAll cookie methods instead of get, set and remove which are deprecated and can be difficult to use correctly.'
85+
);
86+
};
87+
} else {
88+
throw new Error(
89+
'@supabase/ssr: createBrowserClient requires configuring a getAll and setAll cookie method (deprecated: alternatively both get, set and remove can be used)'
90+
);
91+
}
92+
} else if ('getAll' in cookies) {
93+
getAll = async () => await cookies.getAll();
94+
95+
if ('setAll' in cookies) {
96+
setAll = cookies.setAll;
97+
} else if (isServerClient) {
98+
setAll = async () => {
99+
console.warn(
100+
'@supabase/ssr: createServerClient was configured without the setAll cookie method, but the client needs to set cookies. This can lead to issues such as random logouts, early session termination or increased token refresh requests. If in NextJS, check your middleware.ts file, route handlers and server actions for correctness.'
101+
);
102+
};
103+
} else {
104+
throw new Error(
105+
'@supabase/ssr: createBrowserClient requires configuring a getAll and setAll cookie method (deprecated: alternatively both get, set and remove can be used'
106+
);
107+
}
108+
} else {
109+
throw new Error(
110+
'@supabase/ssr: createBrowserClient must be initialized with cookie options that specify getAll and setAll functions (deprecated: alternatively use get, set and remove)'
111+
);
112+
}
113+
} else if (!isServerClient && isBrowser()) {
114+
// The environment is browser, so use the document.cookie API to implement getAll and setAll.
115+
116+
const noHintGetAll = () => {
117+
const parsed = parse(document.cookie);
118+
119+
return Object.keys(parsed).map((name) => ({ name, value: parsed[name] }));
120+
};
121+
122+
getAll = () => noHintGetAll();
123+
124+
setAll = (setCookies) => {
125+
setCookies.forEach(({ name, value, options }) => {
126+
document.cookie = serialize(name, value, options);
127+
});
128+
};
129+
} else if (isServerClient) {
130+
throw new Error(
131+
'@supabase/ssr: createServerClient must be initialized with cookie options that specify getAll and setAll functions (deprecated, not recommended: alternatively use get, set and remove)'
132+
);
133+
} else {
134+
throw new Error(
135+
'@supabase/ssr: createBrowserClient in non-browser runtimes must be initialized with cookie options that specify getAll and setAll functions (deprecated: alternatively use get, set and remove)'
136+
);
137+
}
138+
139+
if (!isServerClient) {
140+
// This is the storage client to be used in browsers. It only
141+
// works on the cookies abstraction, unlike the server client
142+
// which only uses cookies to read the initial state. When an
143+
// item is set, cookies are both cleared and set to values so
144+
// that stale chunks are not left remaining.
145+
return {
146+
getAll, // for type consistency
147+
setAll, // for type consistency
148+
setItems, // for type consistency
149+
removedItems, // for type consistency
150+
storage: {
151+
isServer: false,
152+
getItem: async (key: string) => {
153+
const allCookies = await getAll(key);
154+
const chunkedCookie = await combineChunks(key, async (chunkName: string) => {
155+
const cookie = allCookies?.find(({ name }) => name === chunkName) || null;
156+
157+
if (!cookie) {
158+
return null;
159+
}
160+
161+
return cookie.value;
162+
});
163+
164+
return chunkedCookie || null;
165+
},
166+
setItem: async (key: string, value: string) => {
167+
const allCookies = await getAll(key);
168+
const cookieNames = allCookies?.map(({ name }) => name) || [];
169+
170+
const removeCookies = new Set(cookieNames.filter((name) => isChunkLike(name, key)));
171+
172+
const setCookies = createChunks(key, value);
173+
174+
setCookies.forEach(({ name }) => {
175+
removeCookies.delete(name);
176+
});
177+
178+
const removeCookieOptions = {
179+
...DEFAULT_COOKIE_OPTIONS,
180+
...options?.cookieOptions,
181+
maxAge: 0
182+
};
183+
const setCookieOptions = {
184+
...DEFAULT_COOKIE_OPTIONS,
185+
...options?.cookieOptions,
186+
maxAge: DEFAULT_COOKIE_OPTIONS.maxAge
187+
};
188+
189+
const allToSet = [
190+
...[...removeCookies].map((name) => ({
191+
name,
192+
value: '',
193+
options: removeCookieOptions
194+
})),
195+
...setCookies.map(({ name, value }) => ({ name, value, options: setCookieOptions }))
196+
];
197+
198+
if (allToSet.length > 0) {
199+
await setAll(allToSet);
200+
}
201+
},
202+
removeItem: async (key: string) => {
203+
const allCookies = await getAll(key);
204+
const cookieNames = allCookies?.map(({ name }) => name) || [];
205+
const removeCookies = cookieNames.filter((name) => isChunkLike(name, key));
206+
207+
const removeCookieOptions = {
208+
...DEFAULT_COOKIE_OPTIONS,
209+
...options?.cookieOptions,
210+
maxAge: 0
211+
};
212+
213+
if (removeCookies.length > 0) {
214+
await setAll(
215+
removeCookies.map((name) => ({ name, value: '', options: removeCookieOptions }))
216+
);
217+
}
218+
}
219+
}
220+
};
221+
}
222+
223+
// This is the server client. It only uses getAll to read the initial
224+
// state. Any subsequent changes to the items is persisted in the
225+
// setItems and removedItems objects. createServerClient *must* use
226+
// getAll, setAll and the values in setItems and removedItems to
227+
// persist the changes *at once* when appropriate (usually only when
228+
// the TOKEN_REFRESHED, USER_UPDATED or SIGNED_OUT events are fired by
229+
// the Supabase Auth client).
230+
return {
231+
getAll,
232+
setAll,
233+
setItems,
234+
removedItems,
235+
storage: {
236+
// to signal to the libraries that these cookies are
237+
// coming from a server environment and their value
238+
// should not be trusted
239+
isServer: true,
240+
getItem: async (key: string) => {
241+
if (typeof setItems[key] === 'string') {
242+
return setItems[key];
243+
}
244+
245+
if (removedItems[key]) {
246+
return null;
247+
}
248+
249+
const allCookies = await cookies.getAll();
250+
const chunkedCookie = await combineChunks(key, async (chunkName: string) => {
251+
const cookie = allCookies?.find(({ name }) => name === chunkName) || null;
252+
253+
if (!cookie) {
254+
return null;
255+
}
256+
257+
return cookie.value;
258+
});
259+
260+
return chunkedCookie || null;
261+
},
262+
setItem: async (key: string, value: string) => {
263+
setItems[key] = value;
264+
delete removedItems[key];
265+
},
266+
removeItem: async (key: string) => {
267+
delete setItems[key];
268+
removedItems[key] = true;
269+
}
270+
}
271+
};
272+
}

0 commit comments

Comments
 (0)