Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
branches: [main]

env:
NODE_VER: 22.18
NODE_VER: 24.13
CI: true

jobs:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
workflow_dispatch:

env:
NODE_VER: 22.18
NODE_VER: 24.13
CI: true

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pkg.pr.new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
pull_request:

env:
NODE_VER: 22.18
NODE_VER: 24.13

jobs:
build:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ permissions:
id-token: write # required for npm provenance

env:
NODE_VER: 22.18
NODE_VER: 24.13
CI: true

jobs:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"@nuxt/module-builder": "^1.0.2",
"@nuxt/schema": "^3.20.2",
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@types/node": "^20.19.29",
"@types/node": "^24.10.11",
"eslint": "^9.39.2",
"nuxt": "^3.20.2",
"ofetch": "^1.5.1",
Expand Down
3 changes: 0 additions & 3 deletions playground-local/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
export default defineNuxtConfig({
compatibilityDate: '2024-04-03',
modules: ['../src/module.ts'],
build: {
transpile: ['jsonwebtoken']
},
auth: {
provider: {
type: 'local',
Expand Down
7 changes: 3 additions & 4 deletions playground-local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@
"test:e2e": "vitest"
},
"dependencies": {
"jsonwebtoken": "^9.0.3",
"jose": "^6.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@nuxt/test-utils": "^3.23.0",
"@playwright/test": "^1.57.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.29",
"@playwright/test": "^1.58.2",
"@types/node": "^24.10.11",
"@vue/test-utils": "^2.4.6",
"nuxt": "^3.20.2",
"typescript": "^5.8.3",
Expand Down
14 changes: 11 additions & 3 deletions playground-local/server/api/auth/refresh.post.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createError, eventHandler, getRequestHeader, readBody } from 'h3'
import { checkUserTokens, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser, refreshUserAccessToken } from '~/server/utils/session'
import { checkUserTokens, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser, refreshUserAccessToken, userSchema } from '~/server/utils/session'

/*
* DISCLAIMER!
Expand All @@ -19,16 +19,24 @@ export default eventHandler(async (event) => {
}

// Verify
const decoded = decodeToken(refreshToken)
const decoded = await decodeToken(refreshToken)
if (!decoded) {
throw createError({
statusCode: 401,
message: 'Unauthorized, refreshToken can\'t be verified'
})
}

const user = userSchema.safeParse(decoded)
if (!user.success) {
throw createError({
statusCode: 401,
message: 'Unauthorized, user shape mismatch'
})
}

// Get the helper (only for demo, use a DB in your implementation)
const userTokens = getTokensByUser(decoded.username)
const userTokens = getTokensByUser(user.data.username)
if (!userTokens) {
throw createError({
statusCode: 401,
Expand Down
16 changes: 10 additions & 6 deletions playground-local/server/api/auth/user.get.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import { createError, eventHandler, getRequestHeader } from 'h3'
import { checkUserAccessToken, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser } from '~/server/utils/session'
import type { JwtPayload } from '~/server/utils/session'
import { checkUserAccessToken, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser, userSchema } from '~/server/utils/session'
import type { User } from '~/server/utils/session'

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

const requestAccessToken = extractTokenFromAuthorizationHeader(authorizationHeader)
let decoded: JwtPayload
let decoded: User
try {
const decodeTokenResult = decodeToken(requestAccessToken)
const decodeTokenResult = await decodeToken(requestAccessToken)

if (!decodeTokenResult) {
throw new Error('Expected decoded JwtPayload to be non-empty')
}
decoded = decodeTokenResult
const userParseResult = userSchema.safeParse(decodeTokenResult)
if (!userParseResult.success) {
throw new Error('User shape mismatched')
}
decoded = userParseResult.data
}
catch (error) {
console.error({
Expand Down
74 changes: 36 additions & 38 deletions playground-local/server/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,26 @@
* This is a demo implementation, please create your own handlers
*/

import { sign, verify } from 'jsonwebtoken'
import { jwtVerify, SignJWT } from 'jose'
import type { JWTPayload } from 'jose'
import { z } from 'zod'

/**
* This is a demo secret.
* Please ensure that your secret is properly protected.
*/
const SECRET = 'dummy'
const SECRET = new TextEncoder().encode('dummy')

/** 30 seconds */
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
}
export const userSchema = z.object({
username: z.string().min(1),
name: z.string(),
picture: z.string().optional(),
scope: z.enum(['test', 'user']).array().optional(),
})
export type User = z.infer<typeof userSchema>

interface TokensByUser {
access: Map<string, string>
Expand Down Expand Up @@ -68,15 +66,10 @@ interface UserTokens {
* Demo function for signing user tokens.
* Your implementation may differ.
*/
export function createUserTokens(user: User): Promise<UserTokens> {
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
})
export async function createUserTokens(user: User): Promise<UserTokens> {
const tokenData: JWTPayload = { ...user, scope: ['test', 'user'] }
const accessToken = await createSignedJwt(tokenData, ACCESS_TOKEN_TTL)
const refreshToken = await createSignedJwt(tokenData, /* 1 day */ 60 * 60 * 24)

// Naive implementation - please implement properly yourself!
const userTokens: TokensByUser = tokensByUser.get(user.username) ?? {
Expand All @@ -87,18 +80,18 @@ export function createUserTokens(user: User): Promise<UserTokens> {
userTokens.refresh.set(refreshToken, accessToken)
tokensByUser.set(user.username, userTokens)

// Emulate async work
return Promise.resolve({
return {
accessToken,
refreshToken
})
}
}

/**
* Function for getting the data from a JWT
*/
export function decodeToken(token: string): JwtPayload | undefined {
return verify(token, SECRET) as JwtPayload | undefined
export async function decodeToken(token: string): Promise<JWTPayload> {
const verified = await jwtVerify(token, SECRET)
return verified.payload
}

/**
Expand Down Expand Up @@ -138,33 +131,29 @@ export function invalidateAccessToken(tokensByUser: TokensByUser, accessToken: s
tokensByUser.access.delete(accessToken)
}

export function refreshUserAccessToken(tokensByUser: TokensByUser, refreshToken: string): Promise<UserTokens | undefined> {
export async function refreshUserAccessToken(tokensByUser: TokensByUser, refreshToken: string): Promise<UserTokens | undefined> {
// Get the access token
const oldAccessToken = tokensByUser.refresh.get(refreshToken)
if (!oldAccessToken) {
// Promises to emulate async work (e.g. of a DB call)
return Promise.resolve(undefined)
return
}

// Invalidate old access token
invalidateAccessToken(tokensByUser, oldAccessToken)

// Get the user data. In a real implementation this is likely a DB call.
// In this demo we simply re-use the existing JWT data
const jwtUser = decodeToken(refreshToken)
const jwtUser = await decodeToken(refreshToken)
if (!jwtUser) {
return Promise.resolve(undefined)
return
}

const user: User = {
username: jwtUser.username,
picture: jwtUser.picture,
name: jwtUser.name
const user = userSchema.safeParse(jwtUser)
if (!user.success) {
return
}

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

Expand All @@ -179,3 +168,12 @@ export function extractTokenFromAuthorizationHeader(authorizationHeader: string)
? authorizationHeader.slice(7)
: authorizationHeader
}

function createSignedJwt(payload: JWTPayload, ttlInSeconds: number): Promise<string> {
const unixTimestampNow = Math.floor(Date.now() / 1000)

return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(unixTimestampNow + ttlInSeconds)
.sign(SECRET)
}
Loading
Loading