Skip to content

Commit

Permalink
enh: provide a demo implementation of refresh provider (#901)
Browse files Browse the repository at this point in the history
  • Loading branch information
phoenix-ru authored Sep 12, 2024
1 parent 734415b commit c32f9b1
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 36 deletions.
15 changes: 10 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
cache: 'pnpm'
cache: "pnpm"

- name: Install deps and prepare types
run: pnpm i && pnpm dev:prepare
Expand Down Expand Up @@ -56,7 +56,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
cache: 'pnpm'
cache: "pnpm"

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

- name: Install deps
run: pnpm i
Expand All @@ -93,7 +93,12 @@ jobs:
# Check building
- run: pnpm build

- name: Run Playwright tests using Vitest
- name: Run Playwright tests using Vitest with refresh disabled
run: pnpm test:e2e
env:
NUXT_AUTH_REFRESH_ENABLED: false

- name: Run Playwright tests using Vitest with refresh enabled
run: pnpm test:e2e

test-playground-authjs:
Expand All @@ -113,7 +118,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
cache: 'pnpm'
cache: "pnpm"

- name: Install deps
run: pnpm i
Expand Down
4 changes: 2 additions & 2 deletions playground-local/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { useAuth } from '#imports'
const { signIn, token, refreshToken, data, status, lastRefreshedAt, signOut, getSession } = useAuth()
const username = ref('')
const password = ref('')
const username = ref('smith')
const password = ref('hunter2')
</script>

<template>
Expand Down
17 changes: 17 additions & 0 deletions playground-local/config/AuthRefreshHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { RefreshHandler } from '../../'

// You may also use a plain object with `satisfies RefreshHandler`, of course!
class CustomRefreshHandler implements RefreshHandler {
init(): void {
console.info('Use the full power of classes to customize refreshHandler!')
}

destroy(): void {
console.info(
'Hover above class properties or go to their definition '
+ 'to learn more about how to craft a refreshHandler'
)
}
}

export default new CustomRefreshHandler()
14 changes: 13 additions & 1 deletion playground-local/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,25 @@ export default defineNuxtConfig({
session: {
dataType: { id: 'string', email: 'string', name: 'string', role: '\'admin\' | \'guest\' | \'account\'', subscriptions: '{ id: number, status: \'ACTIVE\' | \'INACTIVE\' }[]' },
dataResponsePointer: '/'
},
refresh: {
// This is usually a static configuration `true` or `false`.
// We do an environment variable for E2E testing both options.
isEnabled: process.env.NUXT_AUTH_REFRESH_ENABLED !== 'false',
endpoint: { path: '/refresh', method: 'post' },
token: {
signInResponseRefreshTokenPointer: '/token/refreshToken',
refreshRequestTokenPointer: '/refreshToken'
},
}
},
sessionRefresh: {
// Whether to refresh the session every time the browser window is refocused.
enableOnWindowFocus: true,
// 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.
enablePeriodically: 5000
enablePeriodically: 5000,
// Custom refresh handler - uncomment to use
// handler: './config/AuthRefreshHandler'
},
globalAppMiddleware: {
isEnabled: true
Expand Down
80 changes: 71 additions & 9 deletions playground-local/server/api/auth/login.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,85 @@ import { createError, eventHandler, readBody } from 'h3'
import { z } from 'zod'
import { sign } from 'jsonwebtoken'

const refreshTokens: Record<number, Record<string, any>> = {}
/*
* DISCLAIMER!
* This is a demo implementation, please create your own handlers
*/

/**
* This is a demo secret.
* Please ensure that your secret is properly protected.
*/
export const SECRET = 'dummy'

/** 30 seconds */
export const ACCESS_TOKEN_TTL = 30

export interface User {
username: string
name: string
picture: string
}

export interface JwtPayload extends User {
scope: Array<'test' | 'user'>
exp?: number
}

interface TokensByUser {
access: Map<string, string>
refresh: Map<string, string>
}

/**
* Tokens storage.
* You will need to implement your own, connect with DB/etc.
*/
export const tokensByUser: Map<string, TokensByUser> = new Map()

/**
* We use a fixed password for demo purposes.
* You can use any implementation fitting your usecase.
*/
const credentialsSchema = z.object({
username: z.string().min(1),
password: z.literal('hunter2')
})

export default eventHandler(async (event) => {
const result = z.object({ username: z.string().min(1), password: z.literal('hunter2') }).safeParse(await readBody(event))
const result = credentialsSchema.safeParse(await readBody(event))
if (!result.success) {
throw createError({ statusCode: 403, statusMessage: 'Unauthorized, hint: try `hunter2` as password' })
throw createError({
statusCode: 403,
statusMessage: 'Unauthorized, hint: try `hunter2` as password'
})
}

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

const accessToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, { expiresIn })
refreshTokens[refreshToken] = {
accessToken,
user
const tokenData: JwtPayload = { ...user, scope: ['test', 'user'] }
const accessToken = sign(tokenData, SECRET, {
expiresIn: ACCESS_TOKEN_TTL
})
const refreshToken = sign(tokenData, SECRET, {
// 1 day
expiresIn: 60 * 60 * 24
})

// Naive implementation - please implement properly yourself!
const userTokens: TokensByUser = tokensByUser.get(username) ?? {
access: new Map(),
refresh: new Map()
}
userTokens.access.set(accessToken, refreshToken)
userTokens.refresh.set(refreshToken, accessToken)
tokensByUser.set(username, userTokens)

return {
token: {
Expand All @@ -33,3 +89,9 @@ export default eventHandler(async (event) => {
}
}
})

export function extractToken(authorizationHeader: string) {
return authorizationHeader.startsWith('Bearer ')
? authorizationHeader.slice(7)
: authorizationHeader
}
76 changes: 76 additions & 0 deletions playground-local/server/api/auth/refresh.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createError, eventHandler, getRequestHeader, readBody } from 'h3'
import { sign, verify } from 'jsonwebtoken'
import { type JwtPayload, SECRET, type User, extractToken, tokensByUser } from './login.post'

/*
* DISCLAIMER!
* This is a demo implementation, please create your own handlers
*/

export default eventHandler(async (event) => {
const body = await readBody<{ refreshToken: string }>(event)
const authorizationHeader = getRequestHeader(event, 'Authorization')
const refreshToken = body.refreshToken

if (!refreshToken || !authorizationHeader) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized, no refreshToken or no Authorization header'
})
}

// Verify
const decoded = verify(refreshToken, SECRET) as JwtPayload | undefined
if (!decoded) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized, refreshToken can\'t be verified'
})
}

// Get tokens
const userTokens = tokensByUser.get(decoded.username)
if (!userTokens) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized, user is not logged in'
})
}

// Check against known token
const requestAccessToken = extractToken(authorizationHeader)
const knownAccessToken = userTokens.refresh.get(body.refreshToken)
if (!knownAccessToken || knownAccessToken !== requestAccessToken) {
console.log({
msg: 'Tokens mismatch',
knownAccessToken,
requestAccessToken
})
throw createError({
statusCode: 401,
statusMessage: 'Tokens mismatch - this is not good'
})
}

// Invalidate old access token
userTokens.access.delete(knownAccessToken)

const user: User = {
username: decoded.username,
picture: decoded.picture,
name: decoded.name
}

const accessToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, {
expiresIn: 60 * 5 // 5 minutes
})
userTokens.refresh.set(refreshToken, accessToken)
userTokens.access.set(accessToken, refreshToken)

return {
token: {
accessToken,
refreshToken
}
}
})
47 changes: 28 additions & 19 deletions playground-local/server/api/auth/user.get.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
import type { H3Event } from 'h3'
import { createError, eventHandler, getRequestHeader } from 'h3'
import { verify } from 'jsonwebtoken'
import { SECRET } from './login.post'
import { type JwtPayload, SECRET, extractToken, tokensByUser } from './login.post'

const TOKEN_TYPE = 'Bearer'

function extractToken(authHeaderValue: string) {
const [, token] = authHeaderValue.split(`${TOKEN_TYPE} `)
return token
}

function ensureAuth(event: H3Event) {
const authHeaderValue = getRequestHeader(event, 'authorization')
if (typeof authHeaderValue === 'undefined') {
export default eventHandler((event) => {
const authorizationHeader = getRequestHeader(event, 'Authorization')
if (typeof authorizationHeader === 'undefined') {
throw createError({ statusCode: 403, statusMessage: 'Need to pass valid Bearer-authorization header to access this endpoint' })
}

const extractedToken = extractToken(authHeaderValue)
const extractedToken = extractToken(authorizationHeader)
let decoded: JwtPayload
try {
return verify(extractedToken, SECRET)
decoded = verify(extractedToken, SECRET) as JwtPayload
}
catch (error) {
console.error('Login failed. Here\'s the raw error:', error)
console.error({
msg: 'Login failed. Here\'s the raw error:',
error
})
throw createError({ statusCode: 403, statusMessage: 'You must be logged in to use this endpoint' })
}
}

export default eventHandler((event) => {
const user = ensureAuth(event)
return user
// Check against known token
const userTokens = tokensByUser.get(decoded.username)
if (!userTokens || !userTokens.access.has(extractedToken)) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized, user is not logged in'
})
}

// All checks successful
const { username, name, picture, scope } = decoded
return {
username,
name,
picture,
scope
}
})

0 comments on commit c32f9b1

Please sign in to comment.