Skip to content

Commit 3ee32b6

Browse files
feat: implement logout functionality to clear client-side and HttpOnly cookies, and add API endpoint for server-side cookie management
1 parent 9542c22 commit 3ee32b6

File tree

2 files changed

+188
-4
lines changed

2 files changed

+188
-4
lines changed

packages/core/src/components/account/MyAccountDrawer/OrganizationDrawer/OrganizationDrawer.tsx

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,128 @@ type OrganizationDrawerProps = {
1313
isRepresentative: boolean
1414
}
1515

16-
export const doLogout = () => {
16+
const clearBrowserStorageForCurrentDomain = async () => {
17+
if (typeof window === 'undefined' || !storeConfig) return
18+
19+
// Clear Faststore-specific sessionStorage keys
20+
try {
21+
const sessionStorageKeys = [
22+
'faststore_session_ready',
23+
'faststore_auth_cookie_value',
24+
'faststore_cache_bust_last_value',
25+
]
26+
27+
for (const key of sessionStorageKeys) {
28+
try {
29+
window.sessionStorage?.removeItem(key)
30+
} catch {}
31+
}
32+
33+
// Remove all keys starting with __fs_gallery_page_ (used for PLP pagination)
34+
try {
35+
const keysToRemove: string[] = []
36+
for (let i = 0; i < window.sessionStorage.length; i++) {
37+
const key = window.sessionStorage.key(i)
38+
if (key && key.startsWith('__fs_gallery_page_')) {
39+
keysToRemove.push(key)
40+
}
41+
}
42+
for (const key of keysToRemove) {
43+
try {
44+
window.sessionStorage.removeItem(key)
45+
} catch {}
46+
}
47+
} catch {}
48+
} catch {}
49+
50+
// Clear all cookies containing 'vtex' in the name (case-insensitive)
51+
try {
52+
const hostname = window.location.hostname
53+
const secure = window.location.protocol === 'https:'
54+
55+
// Extract all cookie names from document.cookie
56+
const allCookieNames = document.cookie
57+
.split(';')
58+
.map((c) => c.trim())
59+
.filter(Boolean)
60+
.map((c) => c.split('=')[0])
61+
.filter(Boolean)
62+
63+
// Filter cookies that contain 'vtex' (case-insensitive)
64+
const vtexCookieNames = allCookieNames.filter((name) =>
65+
name.toLowerCase().includes('vtex')
66+
)
67+
68+
const pathname = window.location.pathname || '/'
69+
const pathParts = pathname.split('/').filter(Boolean)
70+
const paths: string[] = ['/']
71+
72+
let current = ''
73+
for (const part of pathParts) {
74+
current += `/${part}`
75+
if (!paths.includes(current)) paths.push(current)
76+
}
77+
78+
const domains: Array<string | undefined> = [
79+
undefined, // host-only cookie
80+
hostname,
81+
hostname.startsWith('.') ? hostname : `.${hostname}`,
82+
]
83+
84+
const expire = (name: string, path: string, domain?: string) => {
85+
const domainAttr = domain ? `; domain=${domain}` : ''
86+
const secureAttr = secure ? '; secure' : ''
87+
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0; path=${path}${domainAttr}; samesite=lax${secureAttr}`
88+
}
89+
90+
for (const name of vtexCookieNames) {
91+
for (const path of paths) {
92+
for (const domain of domains) {
93+
try {
94+
expire(name, path, domain)
95+
} catch {}
96+
}
97+
}
98+
}
99+
} catch {}
100+
101+
// Clear IndexedDB (keyval-store)
102+
try {
103+
if (!('indexedDB' in window)) return
104+
105+
const idb = window.indexedDB
106+
if (!idb) return
107+
108+
await new Promise<void>((resolve) => {
109+
const req = idb.deleteDatabase('keyval-store')
110+
req.onsuccess = () => resolve()
111+
req.onerror = () => resolve()
112+
req.onblocked = () => resolve()
113+
})
114+
} catch {}
115+
}
116+
117+
export const doLogout = async (_event?: unknown) => {
17118
if (!storeConfig) return
18-
window.location.assign(
19-
`${storeConfig.secureSubdomain}/api/vtexid/pub/logout?scope=${storeConfig.api.storeId}&returnUrl=${storeConfig.storeUrl}`
20-
)
119+
120+
try {
121+
// Clear HttpOnly cookies via API endpoint (server-side)
122+
try {
123+
await fetch('/api/logout', {
124+
method: 'POST',
125+
credentials: 'include',
126+
})
127+
} catch {
128+
// Continue even if API call fails
129+
}
130+
131+
// Clear client-side storage (sessionStorage, localStorage, IndexedDB, non-HttpOnly cookies)
132+
await clearBrowserStorageForCurrentDomain()
133+
} finally {
134+
window.location.assign(
135+
`${storeConfig.secureSubdomain}/api/vtexid/pub/logout?scope=${storeConfig.api.storeId}&returnUrl=${storeConfig.storeUrl}`
136+
)
137+
}
21138
}
22139

23140
export const OrganizationDrawer = ({
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { parse } from 'cookie'
2+
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
3+
4+
import discoveryConfig from 'discovery.config'
5+
6+
const ADDITIONAL_COOKIES = ['CheckoutOrderFormOwnership'] as const
7+
8+
/**
9+
* Clears all cookies containing 'vtex' in the name (case-insensitive) + ADDITIONAL_COOKIES
10+
* This endpoint handles HttpOnly cookies that cannot be cleared via JavaScript
11+
*/
12+
const handler: NextApiHandler = async (
13+
request: NextApiRequest,
14+
response: NextApiResponse
15+
) => {
16+
if (request.method !== 'POST') {
17+
response.status(405).end()
18+
return
19+
}
20+
21+
try {
22+
const hostname = request.headers.host?.split(':')[0] ?? ''
23+
const cookies = parse(request.headers.cookie ?? '')
24+
const domains = [undefined, hostname, `.${hostname}`]
25+
const clearedCookies: string[] = []
26+
27+
const vtexCookieNames = Object.keys(cookies).filter((name) =>
28+
name.toLowerCase().includes('vtex')
29+
)
30+
31+
// Clear vid_rt cookie with specific path (only if refreshToken is enabled)
32+
if (discoveryConfig.experimental?.refreshToken && cookies.vid_rt) {
33+
for (const domain of domains) {
34+
const domainAttr = domain ? `; domain=${domain}` : ''
35+
const clearedCookie = `vid_rt=; expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0; path=/api/vtexid/refreshtoken/webstore${domainAttr}; samesite=lax; httponly`
36+
clearedCookies.push(clearedCookie)
37+
}
38+
}
39+
40+
// Clear other cookies with path /
41+
const otherCookieNames = [
42+
...vtexCookieNames,
43+
...ADDITIONAL_COOKIES.filter((name) => cookies[name]),
44+
]
45+
46+
for (const cookieName of otherCookieNames) {
47+
for (const domain of domains) {
48+
const domainAttr = domain ? `; domain=${domain}` : ''
49+
const clearedCookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0; path=/${domainAttr}; samesite=lax; httponly`
50+
clearedCookies.push(clearedCookie)
51+
}
52+
}
53+
54+
if (clearedCookies.length > 0) {
55+
response.setHeader('set-cookie', clearedCookies)
56+
}
57+
58+
response.status(200).json({ success: true })
59+
} catch (error) {
60+
console.error('Error clearing cookies:', error)
61+
response
62+
.status(500)
63+
.json({ success: false, error: 'Failed to clear cookies' })
64+
}
65+
}
66+
67+
export default handler

0 commit comments

Comments
 (0)