Skip to content

Commit 74d99e9

Browse files
ekzyishuumn
andauthored
Reset multi_auth cookies on error (#1957)
* multi_auth cookies check + reset * multi_auth cookies refresh * Expire cookies after 30 days This is the actual default for next-auth.session-token. * Collapse issues by default * Only refresh session cookie manually as anon * fix mangled merge --------- Co-authored-by: Keyan <[email protected]> Co-authored-by: k00b <[email protected]>
1 parent 71caa6d commit 74d99e9

File tree

5 files changed

+247
-109
lines changed

5 files changed

+247
-109
lines changed

components/account.js

+55-22
Original file line numberDiff line numberDiff line change
@@ -8,40 +8,31 @@ import { useQuery } from '@apollo/client'
88
import { UserListRow } from '@/components/user-list'
99
import Link from 'next/link'
1010
import AddIcon from '@/svgs/add-fill.svg'
11+
import { MultiAuthErrorBanner } from '@/components/banners'
1112

1213
const AccountContext = createContext()
1314

15+
const CHECK_ERRORS_INTERVAL_MS = 5_000
16+
1417
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
15-
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
1618

1719
const maybeSecureCookie = cookie => {
1820
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
1921
}
2022

2123
export const AccountProvider = ({ children }) => {
22-
const { me } = useMe()
2324
const [accounts, setAccounts] = useState([])
2425
const [meAnon, setMeAnon] = useState(true)
26+
const [errors, setErrors] = useState([])
2527

2628
const updateAccountsFromCookie = useCallback(() => {
27-
try {
28-
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
29-
const accounts = multiAuthCookie
30-
? JSON.parse(b64Decode(multiAuthCookie))
31-
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : []
32-
setAccounts(accounts)
33-
// required for backwards compatibility: sync cookie with accounts if no multi auth cookie exists
34-
// this is the case for sessions that existed before we deployed account switching
35-
if (!multiAuthCookie && !!me) {
36-
document.cookie = maybeSecureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
37-
}
38-
} catch (err) {
39-
console.error('error parsing cookies:', err)
40-
}
29+
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
30+
const accounts = multiAuthCookie
31+
? JSON.parse(b64Decode(multiAuthCookie))
32+
: []
33+
setAccounts(accounts)
4134
}, [])
4235

43-
useEffect(updateAccountsFromCookie, [])
44-
4536
const addAccount = useCallback(user => {
4637
setAccounts(accounts => [...accounts, user])
4738
}, [])
@@ -59,15 +50,43 @@ export const AccountProvider = ({ children }) => {
5950
return switchSuccess
6051
}, [updateAccountsFromCookie])
6152

53+
const checkErrors = useCallback(() => {
54+
const {
55+
multi_auth: multiAuthCookie,
56+
'multi_auth.user-id': multiAuthUserIdCookie
57+
} = cookie.parse(document.cookie)
58+
59+
const errors = []
60+
61+
if (!multiAuthCookie) errors.push('multi_auth cookie not found')
62+
if (!multiAuthUserIdCookie) errors.push('multi_auth.user-id cookie not found')
63+
64+
setErrors(errors)
65+
}, [])
66+
6267
useEffect(() => {
6368
if (SSR) return
69+
70+
updateAccountsFromCookie()
71+
6472
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
6573
setMeAnon(multiAuthUserIdCookie === 'anonymous')
66-
}, [])
74+
75+
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
76+
return () => clearInterval(interval)
77+
}, [updateAccountsFromCookie, checkErrors])
6778

6879
const value = useMemo(
69-
() => ({ accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount }),
70-
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount])
80+
() => ({
81+
accounts,
82+
addAccount,
83+
removeAccount,
84+
meAnon,
85+
setMeAnon,
86+
nextAccount,
87+
multiAuthErrors: errors
88+
}),
89+
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount, errors])
7190
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
7291
}
7392

@@ -129,9 +148,23 @@ const AccountListRow = ({ account, ...props }) => {
129148
}
130149

131150
export default function SwitchAccountList () {
132-
const { accounts } = useAccounts()
151+
const { accounts, multiAuthErrors } = useAccounts()
133152
const router = useRouter()
134153

154+
const hasError = multiAuthErrors.length > 0
155+
156+
if (hasError) {
157+
return (
158+
<>
159+
<div className='my-2'>
160+
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
161+
<MultiAuthErrorBanner errors={multiAuthErrors} />
162+
</div>
163+
</div>
164+
</>
165+
)
166+
}
167+
135168
// can't show hat since the streak is not included in the JWT payload
136169
return (
137170
<>

components/banners.js

+22
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useMutation } from '@apollo/client'
66
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
77
import { useToast } from '@/components/toast'
88
import Link from 'next/link'
9+
import AccordianItem from '@/components/accordian-item'
910

1011
export function WelcomeBanner ({ Banner }) {
1112
const { me } = useMe()
@@ -123,3 +124,24 @@ export function AuthBanner () {
123124
</Alert>
124125
)
125126
}
127+
128+
export function MultiAuthErrorBanner ({ errors }) {
129+
return (
130+
<Alert className={styles.banner} key='info' variant='danger'>
131+
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
132+
<AccordianItem
133+
className='my-3'
134+
header='We have detected the following issues:'
135+
headerColor='var(--bs-danger-text-emphasis)'
136+
body={
137+
<ul>
138+
{errors.map((err, i) => (
139+
<li key={i}>{err}</li>
140+
))}
141+
</ul>
142+
}
143+
/>
144+
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
145+
</Alert>
146+
)
147+
}

lib/auth.js

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { datePivot } from '@/lib/time'
2+
import * as cookie from 'cookie'
3+
import { NodeNextRequest } from 'next/dist/server/base-http/node'
4+
import { encode as encodeJWT, decode as decodeJWT } from 'next-auth/jwt'
5+
6+
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
7+
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
8+
9+
const userJwtRegexp = /^multi_auth\.\d+$/
10+
11+
const HTTPS = process.env.NODE_ENV === 'production'
12+
const SESSION_COOKIE_NAME = HTTPS ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
13+
14+
const cookieOptions = (args) => ({
15+
path: '/',
16+
secure: process.env.NODE_ENV === 'production',
17+
// httpOnly cookies by default
18+
httpOnly: true,
19+
sameSite: 'lax',
20+
// default expiration for next-auth JWTs is in 30 days
21+
expires: datePivot(new Date(), { days: 30 }),
22+
...args
23+
})
24+
25+
export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
26+
const httpOnlyOptions = cookieOptions()
27+
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
28+
29+
// add JWT to **httpOnly** cookie
30+
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, httpOnlyOptions))
31+
32+
// switch to user we just added
33+
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, jsOptions))
34+
35+
let newMultiAuth = [{ id, name, photoId }]
36+
if (req.cookies.multi_auth) {
37+
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
38+
// make sure we don't add duplicates
39+
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
40+
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
41+
}
42+
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), jsOptions))
43+
}
44+
45+
export function switchSessionCookie (request) {
46+
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
47+
48+
// is there a cookie pointer?
49+
const cookiePointerName = 'multi_auth.user-id'
50+
const hasCookiePointer = !!request.cookies[cookiePointerName]
51+
52+
// is there a session?
53+
const hasSession = !!request.cookies[SESSION_COOKIE_NAME]
54+
55+
if (!hasCookiePointer || !hasSession) {
56+
// no session or no cookie pointer. do nothing.
57+
return request
58+
}
59+
60+
const userId = request.cookies[cookiePointerName]
61+
if (userId === 'anonymous') {
62+
// user switched to anon. only delete session cookie.
63+
delete request.cookies[SESSION_COOKIE_NAME]
64+
return request
65+
}
66+
67+
const userJWT = request.cookies[`multi_auth.${userId}`]
68+
if (!userJWT) {
69+
// no JWT for account switching found
70+
return request
71+
}
72+
73+
if (userJWT) {
74+
// use JWT found in cookie pointed to by cookie pointer
75+
request.cookies[SESSION_COOKIE_NAME] = userJWT
76+
return request
77+
}
78+
79+
return request
80+
}
81+
82+
export function checkMultiAuthCookies (req, res) {
83+
if (!req.cookies.multi_auth || !req.cookies['multi_auth.user-id']) {
84+
return false
85+
}
86+
87+
const accounts = b64Decode(req.cookies.multi_auth)
88+
for (const account of accounts) {
89+
if (!req.cookies[`multi_auth.${account.id}`]) {
90+
return false
91+
}
92+
}
93+
94+
return true
95+
}
96+
97+
export function resetMultiAuthCookies (req, res) {
98+
const httpOnlyOptions = cookieOptions({ expires: 0, maxAge: 0 })
99+
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
100+
101+
if ('multi_auth' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', '', jsOptions))
102+
if ('multi_auth.user-id' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', '', jsOptions))
103+
104+
for (const key of Object.keys(req.cookies)) {
105+
// reset all user JWTs
106+
if (userJwtRegexp.test(key)) {
107+
res.appendHeader('Set-Cookie', cookie.serialize(key, '', httpOnlyOptions))
108+
}
109+
}
110+
}
111+
112+
export async function refreshMultiAuthCookies (req, res) {
113+
const httpOnlyOptions = cookieOptions()
114+
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
115+
116+
const refreshCookie = (name) => {
117+
res.appendHeader('Set-Cookie', cookie.serialize(name, req.cookies[name], jsOptions))
118+
}
119+
120+
const refreshToken = async (token) => {
121+
const secret = process.env.NEXTAUTH_SECRET
122+
return await encodeJWT({
123+
token: await decodeJWT({ token, secret }),
124+
secret
125+
})
126+
}
127+
128+
const isAnon = req.cookies['multi_auth.user-id'] === 'anonymous'
129+
130+
for (const [key, value] of Object.entries(req.cookies)) {
131+
// only refresh session cookie manually if we switched to anon since else it's already handled by next-auth
132+
if (key === SESSION_COOKIE_NAME && !isAnon) continue
133+
134+
if (!key.startsWith('multi_auth') && key !== SESSION_COOKIE_NAME) continue
135+
136+
if (userJwtRegexp.test(key) || key === SESSION_COOKIE_NAME) {
137+
const oldToken = value
138+
const newToken = await refreshToken(oldToken)
139+
res.appendHeader('Set-Cookie', cookie.serialize(key, newToken, httpOnlyOptions))
140+
continue
141+
}
142+
143+
refreshCookie(key)
144+
}
145+
}
146+
147+
export async function multiAuthMiddleware (req, res) {
148+
if (!req.cookies) {
149+
// required to properly access parsed cookies via req.cookies and not unparsed via req.headers.cookie
150+
req = new NodeNextRequest(req)
151+
}
152+
153+
const ok = checkMultiAuthCookies(req, res)
154+
if (!ok) {
155+
resetMultiAuthCookies(req, res)
156+
return switchSessionCookie(req)
157+
}
158+
159+
await refreshMultiAuthCookies(req, res)
160+
return switchSessionCookie(req)
161+
}

0 commit comments

Comments
 (0)