Skip to content

Commit f147d1a

Browse files
authored
feat: forward set-cookie header for useUserSession().clear() (#282)
* feat: forward set-cookie header for `useUserSession().clear()` * chore: update
1 parent 5d58645 commit f147d1a

File tree

10 files changed

+547
-80
lines changed

10 files changed

+547
-80
lines changed

playground/auth.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,14 @@ declare module '#auth-utils' {
3434
interface UserSession {
3535
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3636
extended?: any
37+
jtw?: {
38+
accessToken: string
39+
refreshToken: string
40+
}
3741
loggedInAt: number
38-
secure?: Record<string, unknown>
42+
}
43+
44+
interface SecureSessionData {
3945
}
4046
}
4147

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { appendResponseHeader } from 'h3'
2+
import { parse, parseSetCookie, serialize } from 'cookie-es'
3+
import type { JwtData } from '@tsndr/cloudflare-worker-jwt'
4+
import { decode } from '@tsndr/cloudflare-worker-jwt'
5+
6+
export default defineNuxtRouteMiddleware(async () => {
7+
const nuxtApp = useNuxtApp()
8+
// Don't run on client hydration when server rendered
9+
if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return
10+
11+
const { session, clear: clearSession, fetch: fetchSession } = useUserSession()
12+
// Ignore if no tokens
13+
if (!session.value?.jwt) return
14+
15+
const serverEvent = useRequestEvent()
16+
const runtimeConfig = useRuntimeConfig()
17+
const { accessToken, refreshToken } = session.value.jwt
18+
19+
const accessPayload = decode(accessToken)
20+
const refreshPayload = decode(refreshToken)
21+
22+
// Both tokens expired, clearing session
23+
if (isExpired(accessPayload) && isExpired(refreshPayload)) {
24+
console.info('both tokens expired, clearing session')
25+
await clearSession()
26+
// return navigateTo('/login')
27+
}
28+
// Access token expired, refreshing
29+
else if (isExpired(accessPayload)) {
30+
console.info('access token expired, refreshing')
31+
await useRequestFetch()('/api/jtw/refresh', {
32+
method: 'POST',
33+
onResponse({ response: { headers } }) {
34+
// Forward the Set-Cookie header to the main server event
35+
if (import.meta.server && serverEvent) {
36+
for (const setCookie of headers.getSetCookie()) {
37+
appendResponseHeader(serverEvent, 'Set-Cookie', setCookie)
38+
// Update session cookie for next fetch requests
39+
const { name, value } = parseSetCookie(setCookie)
40+
if (name === runtimeConfig.session.name) {
41+
// console.log('updating headers.cookie to', value)
42+
const cookies = parse(serverEvent.headers.get('cookie') || '')
43+
// set or overwrite existing cookie
44+
cookies[name] = value
45+
// update cookie event header for future requests
46+
serverEvent.headers.set('cookie', Object.entries(cookies).map(([name, value]) => serialize(name, value)).join('; '))
47+
// Also apply to serverEvent.node.req.headers
48+
if (serverEvent.node?.req?.headers) {
49+
serverEvent.node.req.headers['cookie'] = serverEvent.headers.get('cookie') || ''
50+
}
51+
}
52+
}
53+
}
54+
},
55+
})
56+
// refresh the session
57+
await fetchSession()
58+
}
59+
})
60+
61+
function isExpired(payload: JwtData) {
62+
return payload.payload?.exp && payload.payload.exp < (Date.now() / 1000)
63+
}

playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"dependencies": {
1111
"@iconify-json/gravity-ui": "^1.2.2",
1212
"@iconify-json/iconoir": "^1.2.3",
13+
"@tsndr/cloudflare-worker-jwt": "^3.1.3",
1314
"nuxt": "^3.14.159",
1415
"nuxt-auth-utils": "latest",
1516
"zod": "^3.23.8"

playground/pages/about.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<template>
2+
<UPageBody>
3+
<h1>About page</h1>
4+
<UButton
5+
to="/"
6+
variant="link"
7+
:padded="false"
8+
>
9+
Home page
10+
</UButton>
11+
</UPageBody>
12+
</template>

playground/pages/index.vue

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,24 @@
99
...
1010
</template>
1111
</AuthState>
12-
<UButton
13-
to="/secret"
14-
class="mt-2"
15-
variant="link"
16-
:padded="false"
17-
>
18-
Secret page
19-
</UButton>
12+
<div class="flex flex-col gap-2 mt-4">
13+
<UButton
14+
to="/secret"
15+
class="mt-2"
16+
variant="link"
17+
:padded="false"
18+
>
19+
Secret page
20+
</UButton>
21+
<UButton
22+
to="/about"
23+
class="mt-2"
24+
variant="link"
25+
:padded="false"
26+
>
27+
About page
28+
</UButton>
29+
</div>
2030
</UPageBody>
2131
</UPage>
2232
</template>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import jwt from '@tsndr/cloudflare-worker-jwt'
2+
3+
export default defineEventHandler(async (event) => {
4+
// Get user from session
5+
const user = await getUserSession(event)
6+
if (!user) {
7+
throw createError({
8+
statusCode: 401,
9+
message: 'Unauthorized',
10+
})
11+
}
12+
13+
if (!process.env.NUXT_SESSION_PASSWORD) {
14+
throw createError({
15+
statusCode: 500,
16+
message: 'Session secret not configured',
17+
})
18+
}
19+
20+
// Generate tokens
21+
const accessToken = await jwt.sign(
22+
{
23+
hello: 'world',
24+
exp: Math.floor(Date.now() / 1000) + 5, // 30 seconds
25+
},
26+
process.env.NUXT_SESSION_PASSWORD,
27+
)
28+
29+
const refreshToken = await jwt.sign(
30+
{
31+
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 days
32+
},
33+
`${process.env.NUXT_SESSION_PASSWORD}-secret`,
34+
)
35+
36+
await setUserSession(event, {
37+
jwt: {
38+
accessToken,
39+
refreshToken,
40+
},
41+
loggedInAt: Date.now(),
42+
})
43+
44+
// Return tokens
45+
return {
46+
accessToken,
47+
refreshToken,
48+
}
49+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import jwt from '@tsndr/cloudflare-worker-jwt'
2+
3+
export default eventHandler(async (event) => {
4+
const session = await getUserSession(event)
5+
if (!session.jwt?.accessToken) {
6+
throw createError({
7+
statusCode: 401,
8+
message: 'Unauthorized',
9+
})
10+
}
11+
12+
try {
13+
return await jwt.verify(session.jwt.accessToken, process.env.NUXT_SESSION_PASSWORD!, {
14+
throwError: true,
15+
})
16+
}
17+
catch (err) {
18+
throw createError({
19+
statusCode: 401,
20+
message: (err as Error).message,
21+
})
22+
}
23+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import jwt from '@tsndr/cloudflare-worker-jwt'
2+
3+
export default eventHandler(async (event) => {
4+
const session = await getUserSession(event)
5+
if (!session.jwt?.accessToken && !session.jwt?.refreshToken) {
6+
throw createError({
7+
statusCode: 401,
8+
message: 'Unauthorized',
9+
})
10+
}
11+
12+
if (!await jwt.verify(session.jwt.refreshToken, `${process.env.NUXT_SESSION_PASSWORD!}-secret`)) {
13+
throw createError({
14+
statusCode: 401,
15+
message: 'refresh token is invalid',
16+
})
17+
}
18+
19+
const accessToken = await jwt.sign(
20+
{
21+
hello: 'world',
22+
exp: Math.floor(Date.now() / 1000) + 30, // 30 seconds
23+
},
24+
process.env.NUXT_SESSION_PASSWORD!,
25+
)
26+
27+
await setUserSession(event, {
28+
jwt: {
29+
accessToken,
30+
refreshToken: session.jwt.refreshToken,
31+
},
32+
loggedInAt: Date.now(),
33+
})
34+
35+
return {
36+
accessToken,
37+
refreshToken: session.jwt.refreshToken,
38+
}
39+
})

0 commit comments

Comments
 (0)