Skip to content

Commit c32f9b1

Browse files
authored
enh: provide a demo implementation of refresh provider (#901)
1 parent 734415b commit c32f9b1

File tree

7 files changed

+217
-36
lines changed

7 files changed

+217
-36
lines changed

.github/workflows/ci.yaml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
uses: actions/setup-node@v4
2727
with:
2828
node-version: ${{ env.NODE_VER }}
29-
cache: 'pnpm'
29+
cache: "pnpm"
3030

3131
- name: Install deps and prepare types
3232
run: pnpm i && pnpm dev:prepare
@@ -56,7 +56,7 @@ jobs:
5656
uses: actions/setup-node@v4
5757
with:
5858
node-version: ${{ env.NODE_VER }}
59-
cache: 'pnpm'
59+
cache: "pnpm"
6060

6161
- name: Install deps and prepare types
6262
run: pnpm i && pnpm dev:prepare
@@ -82,7 +82,7 @@ jobs:
8282
uses: actions/setup-node@v4
8383
with:
8484
node-version: ${{ env.NODE_VER }}
85-
cache: 'pnpm'
85+
cache: "pnpm"
8686

8787
- name: Install deps
8888
run: pnpm i
@@ -93,7 +93,12 @@ jobs:
9393
# Check building
9494
- run: pnpm build
9595

96-
- name: Run Playwright tests using Vitest
96+
- name: Run Playwright tests using Vitest with refresh disabled
97+
run: pnpm test:e2e
98+
env:
99+
NUXT_AUTH_REFRESH_ENABLED: false
100+
101+
- name: Run Playwright tests using Vitest with refresh enabled
97102
run: pnpm test:e2e
98103

99104
test-playground-authjs:
@@ -113,7 +118,7 @@ jobs:
113118
uses: actions/setup-node@v4
114119
with:
115120
node-version: ${{ env.NODE_VER }}
116-
cache: 'pnpm'
121+
cache: "pnpm"
117122

118123
- name: Install deps
119124
run: pnpm i

playground-local/app.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { useAuth } from '#imports'
44
55
const { signIn, token, refreshToken, data, status, lastRefreshedAt, signOut, getSession } = useAuth()
66
7-
const username = ref('')
8-
const password = ref('')
7+
const username = ref('smith')
8+
const password = ref('hunter2')
99
</script>
1010

1111
<template>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { RefreshHandler } from '../../'
2+
3+
// You may also use a plain object with `satisfies RefreshHandler`, of course!
4+
class CustomRefreshHandler implements RefreshHandler {
5+
init(): void {
6+
console.info('Use the full power of classes to customize refreshHandler!')
7+
}
8+
9+
destroy(): void {
10+
console.info(
11+
'Hover above class properties or go to their definition '
12+
+ 'to learn more about how to craft a refreshHandler'
13+
)
14+
}
15+
}
16+
17+
export default new CustomRefreshHandler()

playground-local/nuxt.config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,25 @@ export default defineNuxtConfig({
1919
session: {
2020
dataType: { id: 'string', email: 'string', name: 'string', role: '\'admin\' | \'guest\' | \'account\'', subscriptions: '{ id: number, status: \'ACTIVE\' | \'INACTIVE\' }[]' },
2121
dataResponsePointer: '/'
22+
},
23+
refresh: {
24+
// This is usually a static configuration `true` or `false`.
25+
// We do an environment variable for E2E testing both options.
26+
isEnabled: process.env.NUXT_AUTH_REFRESH_ENABLED !== 'false',
27+
endpoint: { path: '/refresh', method: 'post' },
28+
token: {
29+
signInResponseRefreshTokenPointer: '/token/refreshToken',
30+
refreshRequestTokenPointer: '/refreshToken'
31+
},
2232
}
2333
},
2434
sessionRefresh: {
2535
// Whether to refresh the session every time the browser window is refocused.
2636
enableOnWindowFocus: true,
2737
// Whether to refresh the session every `X` milliseconds. Set this to `false` to turn it off. The session will only be refreshed if a session already exists.
28-
enablePeriodically: 5000
38+
enablePeriodically: 5000,
39+
// Custom refresh handler - uncomment to use
40+
// handler: './config/AuthRefreshHandler'
2941
},
3042
globalAppMiddleware: {
3143
isEnabled: true

playground-local/server/api/auth/login.post.ts

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,85 @@ import { createError, eventHandler, readBody } from 'h3'
22
import { z } from 'zod'
33
import { sign } from 'jsonwebtoken'
44

5-
const refreshTokens: Record<number, Record<string, any>> = {}
5+
/*
6+
* DISCLAIMER!
7+
* This is a demo implementation, please create your own handlers
8+
*/
9+
10+
/**
11+
* This is a demo secret.
12+
* Please ensure that your secret is properly protected.
13+
*/
614
export const SECRET = 'dummy'
715

16+
/** 30 seconds */
17+
export const ACCESS_TOKEN_TTL = 30
18+
19+
export interface User {
20+
username: string
21+
name: string
22+
picture: string
23+
}
24+
25+
export interface JwtPayload extends User {
26+
scope: Array<'test' | 'user'>
27+
exp?: number
28+
}
29+
30+
interface TokensByUser {
31+
access: Map<string, string>
32+
refresh: Map<string, string>
33+
}
34+
35+
/**
36+
* Tokens storage.
37+
* You will need to implement your own, connect with DB/etc.
38+
*/
39+
export const tokensByUser: Map<string, TokensByUser> = new Map()
40+
41+
/**
42+
* We use a fixed password for demo purposes.
43+
* You can use any implementation fitting your usecase.
44+
*/
45+
const credentialsSchema = z.object({
46+
username: z.string().min(1),
47+
password: z.literal('hunter2')
48+
})
49+
850
export default eventHandler(async (event) => {
9-
const result = z.object({ username: z.string().min(1), password: z.literal('hunter2') }).safeParse(await readBody(event))
51+
const result = credentialsSchema.safeParse(await readBody(event))
1052
if (!result.success) {
11-
throw createError({ statusCode: 403, statusMessage: 'Unauthorized, hint: try `hunter2` as password' })
53+
throw createError({
54+
statusCode: 403,
55+
statusMessage: 'Unauthorized, hint: try `hunter2` as password'
56+
})
1257
}
1358

14-
const expiresIn = 15
15-
const refreshToken = Math.floor(Math.random() * (1000000000000000 - 1 + 1)) + 1
59+
// Emulate login
1660
const { username } = result.data
1761
const user = {
1862
username,
1963
picture: 'https://github.com/nuxt.png',
2064
name: `User ${username}`
2165
}
2266

23-
const accessToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, { expiresIn })
24-
refreshTokens[refreshToken] = {
25-
accessToken,
26-
user
67+
const tokenData: JwtPayload = { ...user, scope: ['test', 'user'] }
68+
const accessToken = sign(tokenData, SECRET, {
69+
expiresIn: ACCESS_TOKEN_TTL
70+
})
71+
const refreshToken = sign(tokenData, SECRET, {
72+
// 1 day
73+
expiresIn: 60 * 60 * 24
74+
})
75+
76+
// Naive implementation - please implement properly yourself!
77+
const userTokens: TokensByUser = tokensByUser.get(username) ?? {
78+
access: new Map(),
79+
refresh: new Map()
2780
}
81+
userTokens.access.set(accessToken, refreshToken)
82+
userTokens.refresh.set(refreshToken, accessToken)
83+
tokensByUser.set(username, userTokens)
2884

2985
return {
3086
token: {
@@ -33,3 +89,9 @@ export default eventHandler(async (event) => {
3389
}
3490
}
3591
})
92+
93+
export function extractToken(authorizationHeader: string) {
94+
return authorizationHeader.startsWith('Bearer ')
95+
? authorizationHeader.slice(7)
96+
: authorizationHeader
97+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { createError, eventHandler, getRequestHeader, readBody } from 'h3'
2+
import { sign, verify } from 'jsonwebtoken'
3+
import { type JwtPayload, SECRET, type User, extractToken, tokensByUser } from './login.post'
4+
5+
/*
6+
* DISCLAIMER!
7+
* This is a demo implementation, please create your own handlers
8+
*/
9+
10+
export default eventHandler(async (event) => {
11+
const body = await readBody<{ refreshToken: string }>(event)
12+
const authorizationHeader = getRequestHeader(event, 'Authorization')
13+
const refreshToken = body.refreshToken
14+
15+
if (!refreshToken || !authorizationHeader) {
16+
throw createError({
17+
statusCode: 401,
18+
statusMessage: 'Unauthorized, no refreshToken or no Authorization header'
19+
})
20+
}
21+
22+
// Verify
23+
const decoded = verify(refreshToken, SECRET) as JwtPayload | undefined
24+
if (!decoded) {
25+
throw createError({
26+
statusCode: 401,
27+
statusMessage: 'Unauthorized, refreshToken can\'t be verified'
28+
})
29+
}
30+
31+
// Get tokens
32+
const userTokens = tokensByUser.get(decoded.username)
33+
if (!userTokens) {
34+
throw createError({
35+
statusCode: 401,
36+
statusMessage: 'Unauthorized, user is not logged in'
37+
})
38+
}
39+
40+
// Check against known token
41+
const requestAccessToken = extractToken(authorizationHeader)
42+
const knownAccessToken = userTokens.refresh.get(body.refreshToken)
43+
if (!knownAccessToken || knownAccessToken !== requestAccessToken) {
44+
console.log({
45+
msg: 'Tokens mismatch',
46+
knownAccessToken,
47+
requestAccessToken
48+
})
49+
throw createError({
50+
statusCode: 401,
51+
statusMessage: 'Tokens mismatch - this is not good'
52+
})
53+
}
54+
55+
// Invalidate old access token
56+
userTokens.access.delete(knownAccessToken)
57+
58+
const user: User = {
59+
username: decoded.username,
60+
picture: decoded.picture,
61+
name: decoded.name
62+
}
63+
64+
const accessToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, {
65+
expiresIn: 60 * 5 // 5 minutes
66+
})
67+
userTokens.refresh.set(refreshToken, accessToken)
68+
userTokens.access.set(accessToken, refreshToken)
69+
70+
return {
71+
token: {
72+
accessToken,
73+
refreshToken
74+
}
75+
}
76+
})
Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
1-
import type { H3Event } from 'h3'
21
import { createError, eventHandler, getRequestHeader } from 'h3'
32
import { verify } from 'jsonwebtoken'
4-
import { SECRET } from './login.post'
3+
import { type JwtPayload, SECRET, extractToken, tokensByUser } from './login.post'
54

6-
const TOKEN_TYPE = 'Bearer'
7-
8-
function extractToken(authHeaderValue: string) {
9-
const [, token] = authHeaderValue.split(`${TOKEN_TYPE} `)
10-
return token
11-
}
12-
13-
function ensureAuth(event: H3Event) {
14-
const authHeaderValue = getRequestHeader(event, 'authorization')
15-
if (typeof authHeaderValue === 'undefined') {
5+
export default eventHandler((event) => {
6+
const authorizationHeader = getRequestHeader(event, 'Authorization')
7+
if (typeof authorizationHeader === 'undefined') {
168
throw createError({ statusCode: 403, statusMessage: 'Need to pass valid Bearer-authorization header to access this endpoint' })
179
}
1810

19-
const extractedToken = extractToken(authHeaderValue)
11+
const extractedToken = extractToken(authorizationHeader)
12+
let decoded: JwtPayload
2013
try {
21-
return verify(extractedToken, SECRET)
14+
decoded = verify(extractedToken, SECRET) as JwtPayload
2215
}
2316
catch (error) {
24-
console.error('Login failed. Here\'s the raw error:', error)
17+
console.error({
18+
msg: 'Login failed. Here\'s the raw error:',
19+
error
20+
})
2521
throw createError({ statusCode: 403, statusMessage: 'You must be logged in to use this endpoint' })
2622
}
27-
}
2823

29-
export default eventHandler((event) => {
30-
const user = ensureAuth(event)
31-
return user
24+
// Check against known token
25+
const userTokens = tokensByUser.get(decoded.username)
26+
if (!userTokens || !userTokens.access.has(extractedToken)) {
27+
throw createError({
28+
statusCode: 401,
29+
statusMessage: 'Unauthorized, user is not logged in'
30+
})
31+
}
32+
33+
// All checks successful
34+
const { username, name, picture, scope } = decoded
35+
return {
36+
username,
37+
name,
38+
picture,
39+
scope
40+
}
3241
})

0 commit comments

Comments
 (0)