diff --git a/playground-local/nuxt.config.ts b/playground-local/nuxt.config.ts index 75392d66..0d51e9c4 100644 --- a/playground-local/nuxt.config.ts +++ b/playground-local/nuxt.config.ts @@ -8,7 +8,8 @@ export default defineNuxtConfig({ provider: { type: 'local', endpoints: { - getSession: { path: '/user' } + getSession: { path: '/user' }, + signUp: { path: '/signup', method: 'post' } }, pages: { login: '/' diff --git a/playground-local/pages/index.vue b/playground-local/pages/index.vue index 5b9707ab..a87a48b9 100644 --- a/playground-local/pages/index.vue +++ b/playground-local/pages/index.vue @@ -10,6 +10,10 @@ definePageMeta({ auth: false }) -> manual login, logout, refresh button
+ + -> Click to signup + +
-> globally protected page diff --git a/playground-local/pages/register.vue b/playground-local/pages/register.vue new file mode 100644 index 00000000..b5393572 --- /dev/null +++ b/playground-local/pages/register.vue @@ -0,0 +1,45 @@ + + + diff --git a/playground-local/server/api/auth/login.post.ts b/playground-local/server/api/auth/login.post.ts index c03fabe6..877f3077 100644 --- a/playground-local/server/api/auth/login.post.ts +++ b/playground-local/server/api/auth/login.post.ts @@ -1,52 +1,11 @@ import { createError, eventHandler, readBody } from 'h3' -import { z } from 'zod' -import { sign } from 'jsonwebtoken' +import { createUserTokens, credentialsSchema, getUser } from '~/server/utils/session' /* * 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 - refresh: Map -} - -/** - * Tokens storage. - * You will need to implement your own, connect with DB/etc. - */ -export const tokensByUser: Map = 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 = credentialsSchema.safeParse(await readBody(event)) if (!result.success) { @@ -56,42 +15,13 @@ export default eventHandler(async (event) => { }) } - // Emulate login - const { username } = result.data - const user = { - username, - picture: 'https://github.com/nuxt.png', - name: `User ${username}` - } + // Emulate successful login + const user = await getUser(result.data.username) - 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) + // Sign the tokens + const tokens = await createUserTokens(user) return { - token: { - accessToken, - refreshToken - } + token: tokens } }) - -export function extractToken(authorizationHeader: string) { - return authorizationHeader.startsWith('Bearer ') - ? authorizationHeader.slice(7) - : authorizationHeader -} diff --git a/playground-local/server/api/auth/refresh.post.ts b/playground-local/server/api/auth/refresh.post.ts index 45640263..98603e0a 100644 --- a/playground-local/server/api/auth/refresh.post.ts +++ b/playground-local/server/api/auth/refresh.post.ts @@ -1,6 +1,5 @@ import { createError, eventHandler, getRequestHeader, readBody } from 'h3' -import { sign, verify } from 'jsonwebtoken' -import { type JwtPayload, SECRET, type User, extractToken, tokensByUser } from './login.post' +import { checkUserTokens, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser, refreshUserAccessToken } from '~/server/utils/session' /* * DISCLAIMER! @@ -20,7 +19,7 @@ export default eventHandler(async (event) => { } // Verify - const decoded = verify(refreshToken, SECRET) as JwtPayload | undefined + const decoded = decodeToken(refreshToken) if (!decoded) { throw createError({ statusCode: 401, @@ -28,8 +27,8 @@ export default eventHandler(async (event) => { }) } - // Get tokens - const userTokens = tokensByUser.get(decoded.username) + // Get the helper (only for demo, use a DB in your implementation) + const userTokens = getTokensByUser(decoded.username) if (!userTokens) { throw createError({ statusCode: 401, @@ -38,12 +37,12 @@ export default eventHandler(async (event) => { } // Check against known token - const requestAccessToken = extractToken(authorizationHeader) - const knownAccessToken = userTokens.refresh.get(body.refreshToken) - if (!knownAccessToken || knownAccessToken !== requestAccessToken) { + const requestAccessToken = extractTokenFromAuthorizationHeader(authorizationHeader) + const tokensValidityCheck = checkUserTokens(userTokens, requestAccessToken, refreshToken) + if (!tokensValidityCheck.valid) { console.log({ msg: 'Tokens mismatch', - knownAccessToken, + knownAccessToken: tokensValidityCheck.knownAccessToken, requestAccessToken }) throw createError({ @@ -52,25 +51,10 @@ export default eventHandler(async (event) => { }) } - // 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) + // Call the token refresh logic + const tokens = await refreshUserAccessToken(userTokens, refreshToken) return { - token: { - accessToken, - refreshToken - } + token: tokens } }) diff --git a/playground-local/server/api/auth/signup.post.ts b/playground-local/server/api/auth/signup.post.ts new file mode 100644 index 00000000..92ee6cd8 --- /dev/null +++ b/playground-local/server/api/auth/signup.post.ts @@ -0,0 +1,24 @@ +import { createError, eventHandler, readBody } from 'h3' +import { createUserTokens, credentialsSchema, getUser } from '~/server/utils/session' + +export default eventHandler(async (event) => { + const result = credentialsSchema.safeParse(await readBody(event)) + if (!result.success) { + throw createError({ + statusCode: 400, + statusMessage: `Invalid input, please provide a valid username, and a password must be 'hunter2' for this demo.` + }) + } + + // Emulate successful registration + const user = await getUser(result.data.username) + + // Create the sign-in tokens + const tokens = await createUserTokens(user) + + // Return a success response with the email and the token + return { + user, + token: tokens + } +}) diff --git a/playground-local/server/api/auth/user.get.ts b/playground-local/server/api/auth/user.get.ts index d2dde1bf..fbe49a3c 100644 --- a/playground-local/server/api/auth/user.get.ts +++ b/playground-local/server/api/auth/user.get.ts @@ -1,6 +1,5 @@ import { createError, eventHandler, getRequestHeader } from 'h3' -import { verify } from 'jsonwebtoken' -import { type JwtPayload, SECRET, extractToken, tokensByUser } from './login.post' +import { type JwtPayload, checkUserAccessToken, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser } from '~/server/utils/session' export default eventHandler((event) => { const authorizationHeader = getRequestHeader(event, 'Authorization') @@ -8,10 +7,15 @@ export default eventHandler((event) => { throw createError({ statusCode: 403, statusMessage: 'Need to pass valid Bearer-authorization header to access this endpoint' }) } - const extractedToken = extractToken(authorizationHeader) + const requestAccessToken = extractTokenFromAuthorizationHeader(authorizationHeader) let decoded: JwtPayload try { - decoded = verify(extractedToken, SECRET) as JwtPayload + const decodeTokenResult = decodeToken(requestAccessToken) + + if (!decodeTokenResult) { + throw new Error('Expected decoded JwtPayload to be non-empty') + } + decoded = decodeTokenResult } catch (error) { console.error({ @@ -21,9 +25,18 @@ export default eventHandler((event) => { throw createError({ statusCode: 403, statusMessage: 'You must be logged in to use this endpoint' }) } + // Get tokens of a user (only for demo, use a DB in your implementation) + const userTokens = getTokensByUser(decoded.username) + if (!userTokens) { + throw createError({ + statusCode: 404, + statusMessage: 'User not found' + }) + } + // Check against known token - const userTokens = tokensByUser.get(decoded.username) - if (!userTokens || !userTokens.access.has(extractedToken)) { + const tokensValidityCheck = checkUserAccessToken(userTokens, requestAccessToken) + if (!tokensValidityCheck.valid) { throw createError({ statusCode: 401, statusMessage: 'Unauthorized, user is not logged in' diff --git a/playground-local/server/utils/session.ts b/playground-local/server/utils/session.ts new file mode 100644 index 00000000..ddfef461 --- /dev/null +++ b/playground-local/server/utils/session.ts @@ -0,0 +1,181 @@ +/* + * DISCLAIMER! + * This is a demo implementation, please create your own handlers + */ + +import { sign, verify } from 'jsonwebtoken' +import { z } from 'zod' + +/** + * This is a demo secret. + * Please ensure that your secret is properly protected. + */ +const SECRET = '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 +} + +interface TokensByUser { + access: Map + refresh: Map +} + +/** + * Tokens storage. + * You will need to implement your own, connect with DB/etc. + */ +const tokensByUser: Map = new Map() + +/** + * We use a fixed password for demo purposes. + * You can use any implementation fitting your usecase. + */ +export const credentialsSchema = z.object({ + username: z.string().min(1), + password: z.literal('hunter2') +}) + +/** + * Stub function for creating/getting a user. + * Your implementation can use a DB call or any other method. + */ +export function getUser(username: string): Promise { + // Emulate async work + return Promise.resolve({ + username, + picture: 'https://github.com/nuxt.png', + name: `User ${username}` + }) +} + +interface UserTokens { + accessToken: string + refreshToken: string +} + +/** + * Demo function for signing user tokens. + * Your implementation may differ. + */ +export function createUserTokens(user: User): Promise { + 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(user.username) ?? { + access: new Map(), + refresh: new Map() + } + userTokens.access.set(accessToken, refreshToken) + userTokens.refresh.set(refreshToken, accessToken) + tokensByUser.set(user.username, userTokens) + + // Emulate async work + return Promise.resolve({ + accessToken, + refreshToken + }) +} + +/** + * Function for getting the data from a JWT + */ +export function decodeToken(token: string): JwtPayload | undefined { + return verify(token, SECRET) as JwtPayload | undefined +} + +/** + * Helper only for demo purposes. + * Your implementation will likely never need this and will rely on User ID and DB. + */ +export function getTokensByUser(username: string): TokensByUser | undefined { + return tokensByUser.get(username) +} + +type CheckUserTokensResult = { valid: true, knownAccessToken: string } | { valid: false, knownAccessToken: undefined } + +/** + * Function for checking the validity of the access/refresh token pair. + * Your implementation will probably use the DB call. + * @param tokensByUser A helper for demo purposes + */ +export function checkUserTokens(tokensByUser: TokensByUser, requestAccessToken: string, requestRefreshToken: string): CheckUserTokensResult { + const knownAccessToken = tokensByUser.refresh.get(requestRefreshToken) + + return { + valid: !!knownAccessToken && knownAccessToken === requestAccessToken, + knownAccessToken + } as CheckUserTokensResult +} + +export function checkUserAccessToken(tokensByUser: TokensByUser, requestAccessToken: string): CheckUserTokensResult { + const knownAccessToken = tokensByUser.access.has(requestAccessToken) ? requestAccessToken : undefined + + return { + valid: !!knownAccessToken, + knownAccessToken + } as CheckUserTokensResult +} + +export function invalidateAccessToken(tokensByUser: TokensByUser, accessToken: string) { + tokensByUser.access.delete(accessToken) +} + +export function refreshUserAccessToken(tokensByUser: TokensByUser, refreshToken: string): Promise { + // 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) + } + + // 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) + if (!jwtUser) { + return Promise.resolve(undefined) + } + + const user: User = { + username: jwtUser.username, + picture: jwtUser.picture, + name: jwtUser.name + } + + const accessToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, { + expiresIn: 60 * 5 // 5 minutes + }) + tokensByUser.refresh.set(refreshToken, accessToken) + tokensByUser.access.set(accessToken, refreshToken) + + return Promise.resolve({ + accessToken, + refreshToken + }) +} + +export function extractTokenFromAuthorizationHeader(authorizationHeader: string): string { + return authorizationHeader.startsWith('Bearer ') + ? authorizationHeader.slice(7) + : authorizationHeader +} diff --git a/playground-local/tests/local.spec.ts b/playground-local/tests/local.spec.ts index 29abeae5..6337b026 100644 --- a/playground-local/tests/local.spec.ts +++ b/playground-local/tests/local.spec.ts @@ -59,4 +59,35 @@ describe('local Provider', async () => { await signoutButton.click() await playwrightExpect(status).toHaveText(STATUS_UNAUTHENTICATED) }) + + it('should sign up and return signup data when preventLoginFlow: true', async () => { + const page = await createPage('/register') // Navigate to signup page + + const [ + usernameInput, + passwordInput, + submitButton, + status + ] = await Promise.all([ + page.getByTestId('register-username'), + page.getByTestId('register-password'), + page.getByTestId('register-submit'), + page.getByTestId('status') + ]) + + await usernameInput.fill('newuser') + await passwordInput.fill('hunter2') + + // Click button and wait for API to finish + const responsePromise = page.waitForResponse(/\/api\/auth\/signup/) + await submitButton.click() + const response = await responsePromise + + // Expect the response to return signup data + const responseBody = await response.json() // Parse response + playwrightExpect(responseBody).toBeDefined() // Ensure data is returned + + // Since we use `preventLoginFlow`, status should be unauthenticated + await playwrightExpect(status).toHaveText(STATUS_UNAUTHENTICATED) + }) }) diff --git a/src/runtime/composables/local/useAuth.ts b/src/runtime/composables/local/useAuth.ts index 9090717e..bee1aa3d 100644 --- a/src/runtime/composables/local/useAuth.ts +++ b/src/runtime/composables/local/useAuth.ts @@ -1,5 +1,4 @@ import { type Ref, readonly } from 'vue' - import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignInFunc, SignOutFunc, SignUpOptions } from '../../types' import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers' import { _fetch } from '../../utils/fetch' @@ -166,7 +165,7 @@ async function getSession(getSessionOptions?: GetSessionOptions): Promise(credentials: Credentials, signInOptions?: SecondarySignInOptions, signUpOptions?: SignUpOptions): Promise { const nuxt = useNuxtApp() const runtimeConfig = useRuntimeConfig() const config = useTypedBackendConfig(runtimeConfig, 'local') @@ -179,13 +178,15 @@ async function signUp(credentials: Credentials, signInOptions?: SecondarySignInO } const { path, method } = signUpEndpoint - await _fetch(nuxt, path, { + + // Holds result from fetch to be returned if signUpOptions?.preventLoginFlow is true + const result = await _fetch(nuxt, path, { method, body: credentials }) if (signUpOptions?.preventLoginFlow) { - return + return result } return signIn(credentials, signInOptions)