Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enh: provide a demo implementation of refresh provider #901

Merged
merged 5 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
})
Loading