Skip to content

feat: introduce experimental split user and session storage #1023

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
96 changes: 89 additions & 7 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
@@ -33,11 +33,12 @@ import {
uuid,
retryable,
sleep,
supportsLocalStorage,
parseParametersFromURL,
getCodeChallengeAndMethod,
userNotAvailableProxy,
supportsLocalStorage,
} from './lib/helpers'
import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage'
import { memoryLocalStorageAdapter } from './lib/local-storage'
import { polyfillGlobalThis } from './lib/polyfills'
import { version } from './lib/version'
import { LockAcquireTimeoutError, navigatorLock } from './lib/locks'
@@ -97,7 +98,10 @@ import type {

polyfillGlobalThis() // Make "globalThis" available

const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage' | 'lock'> = {
const DEFAULT_OPTIONS: Omit<
Required<GoTrueClientOptions>,
'fetch' | 'storage' | 'userStorage' | 'lock'
> = {
url: GOTRUE_URL,
storageKey: STORAGE_KEY,
autoRefreshToken: true,
@@ -144,6 +148,10 @@ export default class GoTrueClient {
protected autoRefreshToken: boolean
protected persistSession: boolean
protected storage: SupportedStorage
/**
* @experimental
*/
protected userStorage: SupportedStorage | null = null
protected memoryStorage: { [key: string]: string } | null = null
protected stateChangeEmitters: Map<string, Subscription> = new Map()
protected autoRefreshTicker: ReturnType<typeof setInterval> | null = null
@@ -236,12 +244,16 @@ export default class GoTrueClient {
this.storage = settings.storage
} else {
if (supportsLocalStorage()) {
this.storage = localStorageAdapter
this.storage = globalThis.localStorage
} else {
this.memoryStorage = {}
this.storage = memoryLocalStorageAdapter(this.memoryStorage)
}
}

if (settings.userStorage) {
this.userStorage = settings.userStorage
}
} else {
this.memoryStorage = {}
this.storage = memoryLocalStorageAdapter(this.memoryStorage)
@@ -1119,7 +1131,20 @@ export default class GoTrueClient {
)

if (!hasExpired) {
if (this.storage.isServer) {
if (this.userStorage) {
const maybeUser: { user?: User | null } | null = (await getItemAsync(
this.userStorage,
this.storageKey + '-user'
)) as any

if (maybeUser?.user) {
currentSession.user = maybeUser.user
} else {
currentSession.user = userNotAvailableProxy()
}
}

if (this.storage.isServer && currentSession.user) {
let suppressWarning = this.suppressGetSessionWarning
const proxySession: Session = new Proxy(currentSession, {
get: (target: any, prop: string, receiver: any) => {
@@ -1911,7 +1936,47 @@ export default class GoTrueClient {
this._debug(debugName, 'begin')

try {
const currentSession = await getItemAsync(this.storage, this.storageKey)
const currentSession: Session = (await getItemAsync(this.storage, this.storageKey)) as any

if (this.userStorage) {
let maybeUser: { user: User | null } | null = (await getItemAsync(
this.userStorage,
this.storageKey + '-user'
)) as any

if (!this.storage.isServer && Object.is(this.storage, this.userStorage) && !maybeUser) {
// storage and userStorage are the same storage medium, for example
// window.localStorage if userStorage does not have the user from
// storage stored, store it first thereby migrating the user object
// from storage -> userStorage

maybeUser = { user: currentSession.user }
await setItemAsync(this.userStorage, this.storageKey + '-user', maybeUser)
}

currentSession.user = maybeUser?.user ?? userNotAvailableProxy()
} else if (currentSession && !currentSession.user) {
// user storage is not set, let's check if it was previously enabled so
// we bring back the storage as it should be

if (!currentSession.user) {
// test if userStorage was previously enabled and the storage medium was the same, to move the user back under the same key
const separateUser: { user: User | null } | null = (await getItemAsync(
this.storage,
this.storageKey + '-user'
)) as any

if (separateUser && separateUser?.user) {
currentSession.user = separateUser.user

await removeItemAsync(this.storage, this.storageKey + '-user')
await setItemAsync(this.storage, this.storageKey, currentSession)
} else {
currentSession.user = userNotAvailableProxy()
}
}
}

this._debug(debugName, 'session from storage', currentSession)

if (!this._isValidSession(currentSession)) {
@@ -2061,13 +2126,30 @@ export default class GoTrueClient {
// _saveSession is always called whenever a new session has been acquired
// so we can safely suppress the warning returned by future getSession calls
this.suppressGetSessionWarning = true
await setItemAsync(this.storage, this.storageKey, session)

if (this.userStorage) {
await setItemAsync(this.userStorage, this.storageKey + '-user', { user: session.user })

const clone = structuredClone(session) as any // cast intentional as we're deleting the `user` property of a required type below
delete clone.user

await setItemAsync(this.storage, this.storageKey, clone)
} else {
await setItemAsync(this.storage, this.storageKey, session)
}
}

private async _removeSession() {
this._debug('#_removeSession()')

await removeItemAsync(this.storage, this.storageKey)
await removeItemAsync(this.storage, this.storageKey + '-code-verifier')
await removeItemAsync(this.storage, this.storageKey + '-user')

if (this.userStorage) {
await removeItemAsync(this.userStorage, this.storageKey + '-user')
}

await this._notifyAllSubscribers('SIGNED_OUT', null)
}

22 changes: 21 additions & 1 deletion src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { API_VERSION_HEADER_NAME } from './constants'
import { SupportedStorage } from './types'
import { SupportedStorage, User } from './types'

export function expiresAt(expiresIn: number) {
const timeNow = Math.round(Date.now() / 1000)
@@ -344,3 +344,23 @@ export function parseResponseAPIVersion(response: Response) {
return null
}
}

export function userNotAvailableProxy(): User {
return new Proxy({} as User, {
get: (_target: any, prop: string) => {
throw new Error(
`@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.`
)
},
set: (_target: any, prop: string) => {
throw new Error(
`@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.`
)
},
deleteProperty: (_target: any, prop: string) => {
throw new Error(
`@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.`
)
},
})
}
28 changes: 0 additions & 28 deletions src/lib/local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,5 @@
import { supportsLocalStorage } from './helpers'
import { SupportedStorage } from './types'

/**
* Provides safe access to the globalThis.localStorage property.
*/
export const localStorageAdapter: SupportedStorage = {
getItem: (key) => {
if (!supportsLocalStorage()) {
return null
}

return globalThis.localStorage.getItem(key)
},
setItem: (key, value) => {
if (!supportsLocalStorage()) {
return
}

globalThis.localStorage.setItem(key, value)
},
removeItem: (key) => {
if (!supportsLocalStorage()) {
return
}

globalThis.localStorage.removeItem(key)
},
}

/**
* Returns a localStorage-like object that stores the key-value pairs in
* memory.
12 changes: 12 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -69,6 +69,14 @@ export type GoTrueClientOptions = {
persistSession?: boolean
/* Provide your own local storage implementation to use instead of the browser's local storage. */
storage?: SupportedStorage
/**
* 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'`.
*
* 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.
*
* @experimental
*/
userStorage?: SupportedStorage
/* A custom fetch implementation. */
fetch?: Fetch
/* If set to 'pkce' PKCE flow. Defaults to the 'implicit' flow otherwise */
@@ -252,6 +260,10 @@ export interface Session {
*/
expires_at?: number
token_type: string

/**
* When using a separate user storage, accessing properties of this object will throw an error.
*/
user: User
}