Skip to content

Commit e7b2f21

Browse files
hfcemalkilic
andauthored
feat: introduce experimental split user and session storage (#1023)
A common complaint when using Supabase in SSR is that the cookie size is huge. Some server configurations are not able to use such large cookies. A major contributor to cookie size is that the user object is stored alongside the access and refresh tokens. This object _should not be used on the server_ but nevertheless has to exist to make this library happy. This change introduces the ability for this library to store the user object in a separate storage location. For now it's experimental mode to be proofed before being widely adopted. **How does it work?** You can initialize the client by passing in a new option `userStorage` in addition to the already existing and optional `storage` option. By default `userStorage` is not set and a single storage is used for all elements of the session (including `user` property). If `userStorage` is set, **all future changes to the session** will write the user there, and the rest of the session object to `storage`. **Unsolvable Problems** Say you set up the client like so: ```typescript new GoTrueClient(URL, { // ... storage: cookieStorage, userStorage: window.localStorage, }) ``` On the server, the cookies -- obviously -- will not contain the `user` object. Because the `Session` type defines `user: User` as non-nullable, attempting to access a property on this object will throw an exception. Instead you should always call `getUser()` to fetch a trusted and fresh user object. This problem will be solved in v3 of this library. **Testing** [This PR](supabase/supabase#32833) can be used to test this PR before merging. Merging should be safe as this is opt-in behavior for now. --------- Co-authored-by: Cemal Kilic <[email protected]> Co-authored-by: Cemal Kılıç <[email protected]>
1 parent 0aa02d1 commit e7b2f21

File tree

6 files changed

+192
-114
lines changed

6 files changed

+192
-114
lines changed

.github/workflows/dogfooding.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ on:
44
pull_request_review:
55
types: [submitted, edited]
66

7-
pull_request_target:
8-
types:
9-
- opened
10-
branches:
11-
- '*'
7+
pull_request:
8+
9+
push:
10+
11+
permissions:
12+
contents: read
1213

1314
jobs:
1415
check_dogfooding:

src/GoTrueClient.ts

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@ import {
4141
uuid,
4242
retryable,
4343
sleep,
44-
supportsLocalStorage,
4544
parseParametersFromURL,
4645
getCodeChallengeAndMethod,
4746
getAlgorithm,
4847
validateExp,
4948
decodeJWT,
49+
userNotAvailableProxy,
50+
supportsLocalStorage,
5051
} from './lib/helpers'
51-
import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage'
52+
import { memoryLocalStorageAdapter } from './lib/local-storage'
5253
import { polyfillGlobalThis } from './lib/polyfills'
5354
import { version } from './lib/version'
5455
import { LockAcquireTimeoutError, navigatorLock } from './lib/locks'
@@ -114,7 +115,10 @@ import { stringToUint8Array, bytesToBase64URL } from './lib/base64url'
114115

115116
polyfillGlobalThis() // Make "globalThis" available
116117

117-
const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage' | 'lock'> = {
118+
const DEFAULT_OPTIONS: Omit<
119+
Required<GoTrueClientOptions>,
120+
'fetch' | 'storage' | 'userStorage' | 'lock'
121+
> = {
118122
url: GOTRUE_URL,
119123
storageKey: STORAGE_KEY,
120124
autoRefreshToken: true,
@@ -158,6 +162,10 @@ export default class GoTrueClient {
158162
protected autoRefreshToken: boolean
159163
protected persistSession: boolean
160164
protected storage: SupportedStorage
165+
/**
166+
* @experimental
167+
*/
168+
protected userStorage: SupportedStorage | null = null
161169
protected memoryStorage: { [key: string]: string } | null = null
162170
protected stateChangeEmitters: Map<string, Subscription> = new Map()
163171
protected autoRefreshTicker: ReturnType<typeof setInterval> | null = null
@@ -251,12 +259,16 @@ export default class GoTrueClient {
251259
this.storage = settings.storage
252260
} else {
253261
if (supportsLocalStorage()) {
254-
this.storage = localStorageAdapter
262+
this.storage = globalThis.localStorage
255263
} else {
256264
this.memoryStorage = {}
257265
this.storage = memoryLocalStorageAdapter(this.memoryStorage)
258266
}
259267
}
268+
269+
if (settings.userStorage) {
270+
this.userStorage = settings.userStorage
271+
}
260272
} else {
261273
this.memoryStorage = {}
262274
this.storage = memoryLocalStorageAdapter(this.memoryStorage)
@@ -1347,7 +1359,20 @@ export default class GoTrueClient {
13471359
)
13481360

13491361
if (!hasExpired) {
1350-
if (this.storage.isServer) {
1362+
if (this.userStorage) {
1363+
const maybeUser: { user?: User | null } | null = (await getItemAsync(
1364+
this.userStorage,
1365+
this.storageKey + '-user'
1366+
)) as any
1367+
1368+
if (maybeUser?.user) {
1369+
currentSession.user = maybeUser.user
1370+
} else {
1371+
currentSession.user = userNotAvailableProxy()
1372+
}
1373+
}
1374+
1375+
if (this.storage.isServer && currentSession.user) {
13511376
let suppressWarning = this.suppressGetSessionWarning
13521377
const proxySession: Session = new Proxy(currentSession, {
13531378
get: (target: any, prop: string, receiver: any) => {
@@ -2128,7 +2153,47 @@ export default class GoTrueClient {
21282153
this._debug(debugName, 'begin')
21292154

21302155
try {
2131-
const currentSession = await getItemAsync(this.storage, this.storageKey)
2156+
const currentSession: Session = (await getItemAsync(this.storage, this.storageKey)) as any
2157+
2158+
if (this.userStorage) {
2159+
let maybeUser: { user: User | null } | null = (await getItemAsync(
2160+
this.userStorage,
2161+
this.storageKey + '-user'
2162+
)) as any
2163+
2164+
if (!this.storage.isServer && Object.is(this.storage, this.userStorage) && !maybeUser) {
2165+
// storage and userStorage are the same storage medium, for example
2166+
// window.localStorage if userStorage does not have the user from
2167+
// storage stored, store it first thereby migrating the user object
2168+
// from storage -> userStorage
2169+
2170+
maybeUser = { user: currentSession.user }
2171+
await setItemAsync(this.userStorage, this.storageKey + '-user', maybeUser)
2172+
}
2173+
2174+
currentSession.user = maybeUser?.user ?? userNotAvailableProxy()
2175+
} else if (currentSession && !currentSession.user) {
2176+
// user storage is not set, let's check if it was previously enabled so
2177+
// we bring back the storage as it should be
2178+
2179+
if (!currentSession.user) {
2180+
// test if userStorage was previously enabled and the storage medium was the same, to move the user back under the same key
2181+
const separateUser: { user: User | null } | null = (await getItemAsync(
2182+
this.storage,
2183+
this.storageKey + '-user'
2184+
)) as any
2185+
2186+
if (separateUser && separateUser?.user) {
2187+
currentSession.user = separateUser.user
2188+
2189+
await removeItemAsync(this.storage, this.storageKey + '-user')
2190+
await setItemAsync(this.storage, this.storageKey, currentSession)
2191+
} else {
2192+
currentSession.user = userNotAvailableProxy()
2193+
}
2194+
}
2195+
}
2196+
21322197
this._debug(debugName, 'session from storage', currentSession)
21332198

21342199
if (!this._isValidSession(currentSession)) {
@@ -2165,6 +2230,29 @@ export default class GoTrueClient {
21652230
}
21662231
}
21672232
}
2233+
} else if (
2234+
currentSession.user &&
2235+
(currentSession.user as any).__isUserNotAvailableProxy === true
2236+
) {
2237+
// If we have a proxy user, try to get the real user data
2238+
try {
2239+
const { data, error: userError } = await this._getUser(currentSession.access_token)
2240+
2241+
if (!userError && data?.user) {
2242+
currentSession.user = data.user
2243+
await this._saveSession(currentSession)
2244+
await this._notifyAllSubscribers('SIGNED_IN', currentSession)
2245+
} else {
2246+
this._debug(debugName, 'could not get user data, skipping SIGNED_IN notification')
2247+
}
2248+
} catch (getUserError) {
2249+
console.error('Error getting user data:', getUserError)
2250+
this._debug(
2251+
debugName,
2252+
'error getting user data, skipping SIGNED_IN notification',
2253+
getUserError
2254+
)
2255+
}
21682256
} else {
21692257
// no need to persist currentSession again, as we just loaded it from
21702258
// local storage; persisting it again may overwrite a value saved by
@@ -2278,13 +2366,52 @@ export default class GoTrueClient {
22782366
// _saveSession is always called whenever a new session has been acquired
22792367
// so we can safely suppress the warning returned by future getSession calls
22802368
this.suppressGetSessionWarning = true
2281-
await setItemAsync(this.storage, this.storageKey, session)
2369+
2370+
// Create a shallow copy to work with, to avoid mutating the original session object if it's used elsewhere
2371+
const sessionToProcess = { ...session }
2372+
2373+
const userIsProxy =
2374+
sessionToProcess.user && (sessionToProcess.user as any).__isUserNotAvailableProxy === true
2375+
if (this.userStorage) {
2376+
if (!userIsProxy && sessionToProcess.user) {
2377+
// If it's a real user object, save it to userStorage.
2378+
await setItemAsync(this.userStorage, this.storageKey + '-user', {
2379+
user: sessionToProcess.user,
2380+
})
2381+
} else if (userIsProxy) {
2382+
// If it's the proxy, it means user was not found in userStorage.
2383+
// We should ensure no stale user data for this key exists in userStorage if we were to save null,
2384+
// or simply not save the proxy. For now, we don't save the proxy here.
2385+
// If there's a need to clear userStorage if user becomes proxy, that logic would go here.
2386+
}
2387+
2388+
// Prepare the main session data for primary storage: remove the user property before cloning
2389+
// This is important because the original session.user might be the proxy
2390+
const mainSessionData: Omit<Session, 'user'> & { user?: User } = { ...sessionToProcess }
2391+
delete mainSessionData.user // Remove user (real or proxy) before cloning for main storage
2392+
2393+
const clonedMainSessionData = structuredClone(mainSessionData)
2394+
await setItemAsync(this.storage, this.storageKey, clonedMainSessionData)
2395+
} else {
2396+
// No userStorage is configured.
2397+
// In this case, session.user should ideally not be a proxy.
2398+
// If it were, structuredClone would fail. This implies an issue elsewhere if user is a proxy here
2399+
const clonedSession = structuredClone(sessionToProcess) // sessionToProcess still has its original user property
2400+
await setItemAsync(this.storage, this.storageKey, clonedSession)
2401+
}
22822402
}
22832403

22842404
private async _removeSession() {
22852405
this._debug('#_removeSession()')
22862406

22872407
await removeItemAsync(this.storage, this.storageKey)
2408+
await removeItemAsync(this.storage, this.storageKey + '-code-verifier')
2409+
await removeItemAsync(this.storage, this.storageKey + '-user')
2410+
2411+
if (this.userStorage) {
2412+
await removeItemAsync(this.userStorage, this.storageKey + '-user')
2413+
}
2414+
22882415
await this._notifyAllSubscribers('SIGNED_OUT', null)
22892416
}
22902417

src/lib/helpers.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { API_VERSION_HEADER_NAME, BASE64URL_REGEX } from './constants'
22
import { AuthInvalidJwtError } from './errors'
33
import { base64UrlToUint8Array, stringFromBase64URL } from './base64url'
4-
import { JwtHeader, JwtPayload, SupportedStorage } from './types'
4+
import { JwtHeader, JwtPayload, SupportedStorage, User } from './types'
55

66
export function expiresAt(expiresIn: number) {
77
const timeNow = Math.round(Date.now() / 1000)
@@ -365,3 +365,41 @@ export function validateUUID(str: string) {
365365
throw new Error('@supabase/auth-js: Expected parameter to be UUID but is not')
366366
}
367367
}
368+
369+
export function userNotAvailableProxy(): User {
370+
const proxyTarget = {} as User
371+
372+
return new Proxy(proxyTarget, {
373+
get: (target: any, prop: string) => {
374+
if (prop === '__isUserNotAvailableProxy') {
375+
return true
376+
}
377+
// Preventative check for common problematic symbols during cloning/inspection
378+
// These symbols might be accessed by structuredClone or other internal mechanisms.
379+
if (typeof prop === 'symbol') {
380+
const sProp = (prop as symbol).toString()
381+
if (
382+
sProp === 'Symbol(Symbol.toPrimitive)' ||
383+
sProp === 'Symbol(Symbol.toStringTag)' ||
384+
sProp === 'Symbol(util.inspect.custom)'
385+
) {
386+
// Node.js util.inspect
387+
return undefined
388+
}
389+
}
390+
throw new Error(
391+
`@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Accessing the "${prop}" property of the session object is not supported. Please use getUser() instead.`
392+
)
393+
},
394+
set: (_target: any, prop: string) => {
395+
throw new Error(
396+
`@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Setting the "${prop}" property of the session object is not supported. Please use getUser() to fetch a user object you can manipulate.`
397+
)
398+
},
399+
deleteProperty: (_target: any, prop: string) => {
400+
throw new Error(
401+
`@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Deleting the "${prop}" property of the session object is not supported. Please use getUser() to fetch a user object you can manipulate.`
402+
)
403+
},
404+
})
405+
}

src/lib/local-storage.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,5 @@
1-
import { supportsLocalStorage } from './helpers'
21
import { SupportedStorage } from './types'
32

4-
/**
5-
* Provides safe access to the globalThis.localStorage property.
6-
*/
7-
export const localStorageAdapter: SupportedStorage = {
8-
getItem: (key) => {
9-
if (!supportsLocalStorage()) {
10-
return null
11-
}
12-
13-
return globalThis.localStorage.getItem(key)
14-
},
15-
setItem: (key, value) => {
16-
if (!supportsLocalStorage()) {
17-
return
18-
}
19-
20-
globalThis.localStorage.setItem(key, value)
21-
},
22-
removeItem: (key) => {
23-
if (!supportsLocalStorage()) {
24-
return
25-
}
26-
27-
globalThis.localStorage.removeItem(key)
28-
},
29-
}
30-
313
/**
324
* Returns a localStorage-like object that stores the key-value pairs in
335
* memory.

src/lib/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ export type GoTrueClientOptions = {
7070
persistSession?: boolean
7171
/* Provide your own local storage implementation to use instead of the browser's local storage. */
7272
storage?: SupportedStorage
73+
/**
74+
* Stores the user object in a separate storage location from the rest of the session data. When non-null, `storage` will only store a JSON object containing the access and refresh token and some adjacent metadata, while `userStorage` will only contain the user object under the key `storageKey + '-user'`.
75+
*
76+
* When this option is set and cookie storage is used, `getSession()` and other functions that load a session from the cookie store might not return back a user. It's very important to always use `getUser()` to fetch a user object in those scenarios.
77+
*
78+
* @experimental
79+
*/
80+
userStorage?: SupportedStorage
7381
/* A custom fetch implementation. */
7482
fetch?: Fetch
7583
/* If set to 'pkce' PKCE flow. Defaults to the 'implicit' flow otherwise */
@@ -253,6 +261,10 @@ export interface Session {
253261
*/
254262
expires_at?: number
255263
token_type: string
264+
265+
/**
266+
* When using a separate user storage, accessing properties of this object will throw an error.
267+
*/
256268
user: User
257269
}
258270

0 commit comments

Comments
 (0)