From 0c3810580a4ba27d5c3ef1e7ae3e9d05294bbc10 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Fri, 17 Oct 2025 16:58:22 +0200 Subject: [PATCH 01/11] feat(#964): implement hooks provider (WIP) --- src/runtime/composables/hooks/hooks.ts | 57 +++ src/runtime/composables/hooks/types.ts | 95 +++++ src/runtime/composables/hooks/useAuth.ts | 366 ++++++++++++++++++ src/runtime/composables/hooks/useAuthState.ts | 114 ++++++ src/runtime/utils/fetch.ts | 16 +- 5 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 src/runtime/composables/hooks/hooks.ts create mode 100644 src/runtime/composables/hooks/types.ts create mode 100644 src/runtime/composables/hooks/useAuth.ts create mode 100644 src/runtime/composables/hooks/useAuthState.ts diff --git a/src/runtime/composables/hooks/hooks.ts b/src/runtime/composables/hooks/hooks.ts new file mode 100644 index 00000000..a8b8d91a --- /dev/null +++ b/src/runtime/composables/hooks/hooks.ts @@ -0,0 +1,57 @@ +import type { Hooks } from './types' + +export function defineHooks(hooks: Hooks): Hooks { + return hooks +} + +interface Session { + // Data of users returned by `getSession` endpoint +} + +export default defineHooks({ + signIn: { + createRequest(credentials, authState, nuxt) { + // todo + + return { + path: '', + request: { + body: credentials, + } + } + }, + + onResponse(response, authState, nuxt) { + // Possible return values: + // - false - skip any further logic (useful when onResponse handles everything); + // - {} - skip assigning tokens and session, but still possibly call getSession and redirect + // - { token: string } - assign token and continue as normal; + // - { token: string, session: object } - assign token, skip calling getSession, but do possibly call redirect; + + // todo + return { + + } + }, + }, + + getSession: { + createRequest(data, authState, nuxt) { + // todo + + return { + path: '', + request: {} + } + }, + + onResponse(response, authState, nuxt) { + return response._data as Session + } + }, + + // signOut: { + // + // } +}) + diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts new file mode 100644 index 00000000..df8e708a --- /dev/null +++ b/src/runtime/composables/hooks/types.ts @@ -0,0 +1,95 @@ +import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack' +import type { CommonUseAuthStateReturn, GetSessionOptions, SecondarySignInOptions, SignUpOptions } from '../../types' +import type { useNuxtApp } from '#imports' +import type { FetchResponse } from 'ofetch' + +export type RequestOptions = NitroFetchOptions +type NuxtApp = ReturnType +type Awaitable = T | Promise + +/** + * The main interface defining hooks for an endpoint + */ +export interface EndpointHooks { + createRequest( + data: CreateRequestData, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ): Awaitable + + onResponse( + response: FetchResponse, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ): Awaitable +} + +/** Object that needs to be returned from `createRequest` in order to continue with data fetching */ +export interface CreateRequestResult { + /** + * Path to be provided to `$fetch`. + * It can start with `/` so that Nuxt would use function calls on server. + */ + path: string + /** + * Request to be provided to `$fetch`, can include method, body, params, etc. + * @see https://nuxt.com/docs/4.x/api/utils/dollarfetch + */ + request: RequestOptions +} + +/** Credentials accepted by `signIn` function */ +export interface Credentials extends Record { + username?: string + email?: string + password?: string +} + +/** Data provided to `signIn.createRequest` */ +export interface SignInCreateRequestData { + credentials: Credentials + options?: SecondarySignInOptions +} + +/** +* Object that can be returned from some `onResponse` endpoints in order to update the auth state +* and impact the next steps. +*/ +export interface ResponseAccept { + /** + * The value of the access token to be set. + * Omit or set to `undefined` to not modify the value. + */ + token?: string | null + + /** Omit or set to `undefined` if you don't use it */ + refreshToken?: string + + /** + * When the session is provided, method will not call `getSession` and the session will be returned. + * Otherwise `getSession` may be called: + * - for `signIn` and `signUp` - depending on `callGetSession`; + * - for `refresh` - `getSession` will always be called in this case. + */ + session?: SessionDataType +} + +/** Data provided to `signIn.createRequest` */ +export interface SignUpCreateRequestData { + credentials: Credentials + options?: SignUpOptions +} + +// TODO Use full UseAuthStateReturn, not the CommonUseAuthStateReturn + +export interface Hooks { + // Required endpoints + signIn: EndpointHooks> + getSession: EndpointHooks + + // Optional endpoints + signOut?: EndpointHooks + signUp?: EndpointHooks | undefined> + refresh?: EndpointHooks> +} + diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts new file mode 100644 index 00000000..7258ca8a --- /dev/null +++ b/src/runtime/composables/hooks/useAuth.ts @@ -0,0 +1,366 @@ +import { readonly } from 'vue' +import type { Ref } from 'vue' +import type { FetchResponse } from 'ofetch' +import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' +import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers' +import { _fetch, _fetchRaw } from '../../utils/fetch' +import { getRequestURLWN } from '../common/getRequestURL' +import { ERROR_PREFIX } from '../../utils/logger' +import { determineCallbackUrl } from '../../utils/callbackUrl' +import { formatToken } from './utils/token' +import { useAuthState } from './useAuthState' +// @ts-expect-error - #auth not defined +import type { SessionData } from '#auth' +import { navigateTo, nextTick, useNuxtApp, useRoute, useRuntimeConfig } from '#imports' + +import userHooks, { type Credentials, type RequestOptions } from './hooks' +import type { ResponseAccept } from './types' + +export interface SignInFunc> { + ( + credentials: Credentials, + signInOptions?: SecondarySignInOptions, + paramsOptions?: Record, + headersOptions?: Record + ): Promise +} + +export interface SignUpFunc> { + (credentials: Credentials, signUpOptions?: SignUpOptions): Promise +} + +export interface SignOutFunc { + (options?: SignOutOptions): Promise +} + +/** + * Returns an extended version of CommonUseAuthReturn with local-provider specific data + * + * @remarks + * The returned value of `refreshToken` will always be `null` if `refresh.isEnabled` is `false` + */ +interface UseAuthReturn extends CommonUseAuthReturn { + signUp: SignUpFunc + token: Readonly> + refreshToken: Readonly> +} + +export function useAuth(): UseAuthReturn { + const nuxt = useNuxtApp() + const runtimeConfig = useRuntimeConfig() + const config = useTypedBackendConfig(runtimeConfig, 'local') + + const authState = useAuthState() + const { + data, + status, + lastRefreshedAt, + loading, + token, + refreshToken, + rawToken, + rawRefreshToken, + _internal + } = authState + + async function signIn>( + credentials: Credentials, + options?: SecondarySignInOptions, + ): Promise { + const hooks = userHooks.signIn + + const createRequestResult = await Promise.resolve(hooks.createRequest({ credentials, options }, authState, nuxt)) + if (createRequestResult === false) { + return + } + + const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + + const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signInResponseAccept === false) { + return + } + + const { redirect = true, external, callGetSession = true } = options ?? {} + + await acceptResponse(signInResponseAccept, callGetSession) + + if (redirect) { + let callbackUrl = options?.callbackUrl + if (typeof callbackUrl === 'undefined') { + const redirectQueryParam = useRoute()?.query?.redirect + callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString()) + } + + await navigateTo(callbackUrl, { external }) + return + } + + return response._data + } + + /** + * Helper function for handling user-returned data from `onResponse` + */ + async function acceptResponse( + responseAccept: ResponseAccept, + callGetSession: boolean, + getSessionOptions?: GetSessionOptions, + ) { + if (responseAccept.token !== undefined) { + // Token was returned, save it + rawToken.value = responseAccept.token + } + + if (config.refresh.isEnabled && responseAccept.refreshToken !== undefined) { + // Refresh token was returned, save it + rawRefreshToken.value = responseAccept.refreshToken + } + + if (responseAccept.session !== undefined) { + // Session was returned, use it and avoid calling getSession + data.value = responseAccept.session + lastRefreshedAt.value = new Date() + } + else if (callGetSession) { + await nextTick() + return await getSession(getSessionOptions) + } + } + + async function signOut(signOutOptions?: SignOutOptions): Promise { + // TODO Migrate to hooks + const signOutConfig = config.endpoints.signOut + + let request: RequestOptions | undefined + if (signOutConfig) { + request = { + method: signOutConfig.method, + headers: new Headers({ [config.token.headerName]: token.value } as HeadersInit), + } + + // If the refresh provider is used, include the refreshToken in the body + if (config.refresh.isEnabled && ['post', 'put', 'patch', 'delete'].includes(signOutConfig.method.toLowerCase())) { + // This uses refresh token pointer as we are passing `refreshToken` + const signoutRequestRefreshTokenPointer = config.refresh.token.refreshRequestTokenPointer + request.body = objectFromJsonPointer(signoutRequestRefreshTokenPointer, refreshToken.value) + } + } + + data.value = null + rawToken.value = null + rawRefreshToken.value = null + + let res: T | undefined + if (signOutConfig) { + const { path } = signOutConfig + + const hooks = userHooks.signOut + if (hooks) { + const canContinue = await Promise.resolve(hooks.createRequest(undefined, authState, nuxt)) + if (canContinue === false) { + return + } + + const response = await _fetchRaw(nuxt, path, request) + signInResponseData = response._data + + const signInResponseAccept = await Promise.resolve(hooks.onResponse?.(response, authState, nuxt)) + if (signInResponseAccept === false) { + return + } + } else { + res = await _fetch(nuxt, path, request) + } + } + + const { redirect = true, external } = signOutOptions ?? {} + + if (redirect) { + let callbackUrl = signOutOptions?.callbackUrl + if (typeof callbackUrl === 'undefined') { + const redirectQueryParam = useRoute()?.query?.redirect + callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString(), true) + } + await navigateTo(callbackUrl, { external }) + } + + return res + } + + async function getSession(getSessionOptions?: GetSessionOptions): Promise { + // Create request + const hooks = userHooks.getSession + const createRequestResult = await Promise.resolve(hooks.createRequest(getSessionOptions, authState, nuxt)) + if (createRequestResult === false) { + return + } + + // Fetch + let response: FetchResponse + loading.value = true + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } finally { + loading.value = false + } + + lastRefreshedAt.value = new Date() + + // Use response + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (getSessionResponseAccept === false) { + return + } + + data.value = getSessionResponseAccept + + // TODO Do use cookies for storing access and refresh tokens, but only to provide them to authState. + // How to handle the TTL though? (probably use existing Max-Age and other cookie settings; disallow HTTP-Only?) + + // TODO Add this to README FAQ: + // ## My server returns HTTP-Only cookies + // You are already set in this case - your browser will automatically send cookies with each request, + // as soon as the cookies were configured with the correct domain and path on your server. + // NuxtAuth will use `getSession` to query your server - this is how your application + // will know the authentication status. + // + // Please also note that `authState` will not have the tokens available in this case. + // + // ## My server returns tokens inside Body or Headers + // In this case you should extract the tokens inside `onResponse` hook and let NuxtAuth know about them + // by returning them from the hook, e.g. + // ```ts + // return { + // token: response._data.accessToken, + // refreshToken: response.headers.get('X-RefreshToken'), + // } + // ``` + // + // NuxtAuth will update `authState` accordingly, so you will be able to use the tokens in the later calls. + // The tokens you return will be internally stored inside cookies and + // you can configure their Max-Age (refer to the relevant documentation). + + // TODO Document accepting the response by different hooks: + // ## All hooks + // false + // Stops the function execution, does not update anything or trigger any other logic. + // Useful when hook already handled everything. + // + // Throw Error + // Stops the execution and propagates the error without handling it. + // You should be very careful when throwing from `signIn` as it is also used inside middleware. + // + // ## signIn + // Object, depending on which properties are set, will update authState and trigger other logic. + // + // ## getSession + // null - will clear the session. If `required` was used during `getSession` call, + // it will call `onUnauthenticated` or navigate the user away. + // + // Any other value - will set the session to this value. + // + // ## signOut + // + // ## signUp + // Same as `signIn`, response can be accepted using an object, + // in this case `authState` will be updated and function will return. + // + // Response can also be accepted with `undefined`, + // this will trigger `signIn` flow unless `preventLoginFlow` was given. + + // TODO Mention that `force` option does not have any effect in this provider + // TODO Deprecate the `force` option altogether in favor of a cookie-less `getSession` (and/or deprecate `local` provider) + + const { required = false, callbackUrl, onUnauthenticated, external } = getSessionOptions ?? {} + if (required && data.value === null) { + if (onUnauthenticated) { + return onUnauthenticated() + } + await navigateTo(callbackUrl ?? await getRequestURLWN(nuxt), { external }) + } + + return data.value + } + + async function signUp(credentials: Credentials, options?: SignUpOptions): Promise { + const hooks = userHooks.signUp + if (!hooks) { + console.warn(`${ERROR_PREFIX} signUp endpoint has not been configured.`) + return + } + + const createRequestResult = await Promise.resolve(hooks.createRequest({ credentials, options }, authState, nuxt)) + if (createRequestResult === false) { + return + } + + const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + + const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signUpResponseAccept === false) { + return + } else if (signUpResponseAccept !== undefined) { + // When an object was returned, accept it the same way as for `signIn` + await acceptResponse(signUpResponseAccept, options?.callGetSession ?? false) + return response._data + } + + if (options?.preventLoginFlow) { + return response._data + } + + // When response was accepted with `undefined` and `preventLoginFlow` was not `true`, + // proceed with sign-in. + return signIn(credentials, options) + } + + async function refresh(options?: GetSessionOptions) { + const hooks = userHooks.refresh + + // When no specific refresh endpoint was defined, use a regular `getSession` + if (!hooks) { + return getSession(options) + } + + // TODO Re-check the implementation - assume that any of these can be returned: + // - new session; + // - new access token; + // - new refresh token; + + // Create request + const createRequestResult = await Promise.resolve(hooks.createRequest(options, authState, nuxt)) + if (createRequestResult === false) { + return + } + + // Fetch + const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + + // Use response + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (getSessionResponseAccept === false) { + return + } else if (getSessionResponseAccept !== undefined) { + // When an object was returned, accept it the same way as for `signIn` + // and always call `getSession` when session was not provided + return await acceptResponse(getSessionResponseAccept, true, options) + } + + await nextTick() + return await getSession(options) + } + + return { + status, + data: readonly(data), + lastRefreshedAt: readonly(lastRefreshedAt), + token: readonly(token), + refreshToken: readonly(refreshToken), + getSession, + signIn, + signOut, + signUp, + refresh + } +} diff --git a/src/runtime/composables/hooks/useAuthState.ts b/src/runtime/composables/hooks/useAuthState.ts new file mode 100644 index 00000000..ddbe3e6a --- /dev/null +++ b/src/runtime/composables/hooks/useAuthState.ts @@ -0,0 +1,114 @@ +import { computed, getCurrentInstance, watch } from 'vue' +import type { ComputedRef } from 'vue' +import type { CommonUseAuthStateReturn } from '../../types' +import { makeCommonAuthState } from '../commonAuthState' +import { useTypedBackendConfig } from '../../helpers' +import { formatToken } from './utils/token' +import type { CookieRef } from '#app' +import { onMounted, useCookie, useRuntimeConfig, useState } from '#imports' +// @ts-expect-error - #auth not defined +import type { SessionData } from '#auth' + +/** + * The internal response of the local-specific auth data + * + * @remarks + * The returned value `refreshToken` and `rawRefreshToken` will always be `null` if `refresh.isEnabled` is `false` + */ +export interface UseAuthStateReturn extends CommonUseAuthStateReturn { + token: ComputedRef + rawToken: CookieRef + refreshToken: ComputedRef + rawRefreshToken: CookieRef + setToken: (newToken: string | null) => void + clearToken: () => void + _internal: { + rawTokenCookie: CookieRef + } +} + +export function useAuthState(): UseAuthStateReturn { + const config = useTypedBackendConfig(useRuntimeConfig(), 'local') + const commonAuthState = makeCommonAuthState() + + const instance = getCurrentInstance() + + // Re-construct state from cookie, also setup a cross-component sync via a useState hack, see https://github.com/nuxt/nuxt/issues/13020#issuecomment-1397282717 + const _rawTokenCookie = useCookie(config.token.cookieName, { + default: () => null, + domain: config.token.cookieDomain, + maxAge: config.token.maxAgeInSeconds, + sameSite: config.token.sameSiteAttribute, + secure: config.token.secureCookieAttribute, + httpOnly: config.token.httpOnlyCookieAttribute + }) + const rawToken = useState('auth:raw-token', () => _rawTokenCookie.value) + watch(rawToken, () => { + _rawTokenCookie.value = rawToken.value + }) + + const token = computed(() => formatToken(rawToken.value, config)) + function setToken(newToken: string | null) { + rawToken.value = newToken + } + function clearToken() { + setToken(null) + } + + // When the page is cached on a server, set the token on the client + if (instance) { + onMounted(() => { + if (_rawTokenCookie.value && !rawToken.value) { + setToken(_rawTokenCookie.value) + } + }) + } + + // Handle refresh token, for when refresh logic is enabled + const rawRefreshToken = useState('auth:raw-refresh-token', () => null) + if (config.refresh.isEnabled) { + const _rawRefreshTokenCookie = useCookie(config.refresh.token.cookieName, { + default: () => null, + domain: config.refresh.token.cookieDomain, + maxAge: config.refresh.token.maxAgeInSeconds, + sameSite: config.refresh.token.sameSiteAttribute, + secure: config.refresh.token.secureCookieAttribute, + httpOnly: config.refresh.token.httpOnlyCookieAttribute + }) + + // Set default value if `useState` returned `null` + // https://github.com/sidebase/nuxt-auth/issues/896 + if (rawRefreshToken.value === null) { + rawRefreshToken.value = _rawRefreshTokenCookie.value + } + + watch(rawRefreshToken, () => { + _rawRefreshTokenCookie.value = rawRefreshToken.value + }) + + // When the page is cached on a server, set the refresh token on the client + if (instance) { + onMounted(() => { + if (_rawRefreshTokenCookie.value && !rawRefreshToken.value) { + rawRefreshToken.value = _rawRefreshTokenCookie.value + } + }) + } + } + + const refreshToken = computed(() => rawRefreshToken.value) + + return { + ...commonAuthState, + token, + rawToken, + refreshToken, + rawRefreshToken, + setToken, + clearToken, + _internal: { + rawTokenCookie: _rawTokenCookie + } + } +} +export default useAuthState diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index 644152ec..1e24bb76 100644 --- a/src/runtime/utils/fetch.ts +++ b/src/runtime/utils/fetch.ts @@ -4,13 +4,23 @@ import { useRequestEvent, useRuntimeConfig } from '#imports' import type { useNuxtApp } from '#imports' import { callWithNuxt } from '#app/nuxt' import type { H3Event } from 'h3' +import type { FetchResponse } from 'ofetch' export async function _fetch( nuxt: ReturnType, path: string, fetchOptions?: Parameters[1], - proxyCookies = false + proxyCookies = false, ): Promise { + return _fetchRaw(nuxt, path, fetchOptions, proxyCookies).then(res => res._data as T) +} + +export async function _fetchRaw( + nuxt: ReturnType, + path: string, + fetchOptions?: Parameters[1], + proxyCookies = false, +): Promise> { // This fixes https://github.com/sidebase/nuxt-auth/issues/927 const runtimeConfigOrPromise = callWithNuxt(nuxt, useRuntimeConfig) const runtimeConfig = 'public' in runtimeConfigOrPromise @@ -48,13 +58,13 @@ export async function _fetch( try { // Adapted from https://nuxt.com/docs/getting-started/data-fetching#pass-cookies-from-server-side-api-calls-on-ssr-response - return $fetch.raw(joinedPath, fetchOptions).then((res) => { + return $fetch.raw(joinedPath, fetchOptions).then((res) => { if (import.meta.server && proxyCookies && event) { const cookies = res.headers.getSetCookie() event.node.res.appendHeader('set-cookie', cookies) } - return res._data as T + return res }) } catch (error) { From a1e73ce761910a5d167f673bf8df06fa9b889f04 Mon Sep 17 00:00:00 2001 From: Marsel Shayhin Date: Wed, 5 Nov 2025 14:26:28 +0100 Subject: [PATCH 02/11] feat: implement hooks for signOut --- src/runtime/composables/hooks/types.ts | 6 +- src/runtime/composables/hooks/useAuth.ts | 74 +++++++++++------------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts index df8e708a..47e8caf8 100644 --- a/src/runtime/composables/hooks/types.ts +++ b/src/runtime/composables/hooks/types.ts @@ -1,5 +1,5 @@ import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack' -import type { CommonUseAuthStateReturn, GetSessionOptions, SecondarySignInOptions, SignUpOptions } from '../../types' +import type { CommonUseAuthStateReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' import type { useNuxtApp } from '#imports' import type { FetchResponse } from 'ofetch' @@ -63,7 +63,7 @@ export interface ResponseAccept { token?: string | null /** Omit or set to `undefined` if you don't use it */ - refreshToken?: string + refreshToken?: string | null /** * When the session is provided, method will not call `getSession` and the session will be returned. @@ -88,7 +88,7 @@ export interface Hooks { getSession: EndpointHooks // Optional endpoints - signOut?: EndpointHooks + signOut?: EndpointHooks | undefined> signUp?: EndpointHooks | undefined> refresh?: EndpointHooks> } diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts index 7258ca8a..dd2d0697 100644 --- a/src/runtime/composables/hooks/useAuth.ts +++ b/src/runtime/composables/hooks/useAuth.ts @@ -2,12 +2,11 @@ import { readonly } from 'vue' import type { Ref } from 'vue' import type { FetchResponse } from 'ofetch' import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' -import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers' +import { useTypedBackendConfig } from '../../helpers' import { _fetch, _fetchRaw } from '../../utils/fetch' import { getRequestURLWN } from '../common/getRequestURL' import { ERROR_PREFIX } from '../../utils/logger' import { determineCallbackUrl } from '../../utils/callbackUrl' -import { formatToken } from './utils/token' import { useAuthState } from './useAuthState' // @ts-expect-error - #auth not defined import type { SessionData } from '#auth' @@ -129,49 +128,44 @@ export function useAuth(): UseAuthReturn { } async function signOut(signOutOptions?: SignOutOptions): Promise { - // TODO Migrate to hooks - const signOutConfig = config.endpoints.signOut - - let request: RequestOptions | undefined - if (signOutConfig) { - request = { - method: signOutConfig.method, - headers: new Headers({ [config.token.headerName]: token.value } as HeadersInit), + const hooks = userHooks.signOut + + let res: T | undefined + let shouldResetData = true + + if (hooks) { + // Create request + const createRequestResult = await Promise.resolve(hooks.createRequest(signOutOptions, authState, nuxt)) + if (createRequestResult === false) { + return } - // If the refresh provider is used, include the refreshToken in the body - if (config.refresh.isEnabled && ['post', 'put', 'patch', 'delete'].includes(signOutConfig.method.toLowerCase())) { - // This uses refresh token pointer as we are passing `refreshToken` - const signoutRequestRefreshTokenPointer = config.refresh.token.refreshRequestTokenPointer - request.body = objectFromJsonPointer(signoutRequestRefreshTokenPointer, refreshToken.value) + // Fetch + const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + res = response._data + + // Accept what was returned by the user. + // If `false` was returned - do not proceed. + // `undefined` will reset data and continue with execution. + // Object: + // If a field was set to `null`, it will be reset. + // Omitting a field or setting to `undefined` would not modify it. + // TODO: Document this behaviour + const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signInResponseAccept === false) { + return + } else if (signInResponseAccept !== undefined) { + await acceptResponse(signInResponseAccept, false) + shouldResetData = false } } - data.value = null - rawToken.value = null - rawRefreshToken.value = null - - let res: T | undefined - if (signOutConfig) { - const { path } = signOutConfig - - const hooks = userHooks.signOut - if (hooks) { - const canContinue = await Promise.resolve(hooks.createRequest(undefined, authState, nuxt)) - if (canContinue === false) { - return - } - - const response = await _fetchRaw(nuxt, path, request) - signInResponseData = response._data - - const signInResponseAccept = await Promise.resolve(hooks.onResponse?.(response, authState, nuxt)) - if (signInResponseAccept === false) { - return - } - } else { - res = await _fetch(nuxt, path, request) - } + if (shouldResetData) { + await acceptResponse({ + session: null, + token: null, + refreshToken: null, + }, false) } const { redirect = true, external } = signOutOptions ?? {} From 0b15c6f9063af434570db0202c89d4d9f884c54c Mon Sep 17 00:00:00 2001 From: Marsel Shayhin Date: Wed, 5 Nov 2025 17:10:23 +0100 Subject: [PATCH 03/11] docs: add documentation about Hooks provider --- docs/.vitepress/routes/navbar.ts | 4 + docs/.vitepress/routes/sidebar/guide.ts | 14 ++ docs/guide/hooks/examples.md | 89 +++++++++++++ docs/guide/hooks/quick-start.md | 166 ++++++++++++++++++++++++ src/runtime/composables/hooks/hooks.ts | 6 +- src/runtime/composables/hooks/types.ts | 2 +- 6 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 docs/guide/hooks/examples.md create mode 100644 docs/guide/hooks/quick-start.md diff --git a/docs/.vitepress/routes/navbar.ts b/docs/.vitepress/routes/navbar.ts index 23c6d492..990d6fe2 100644 --- a/docs/.vitepress/routes/navbar.ts +++ b/docs/.vitepress/routes/navbar.ts @@ -17,6 +17,10 @@ export const routes: DefaultTheme.Config['nav'] = [ text: 'Local guide', link: '/guide/local/quick-start', }, + { + text: 'Hooks guide', + link: '/guide/hooks/quick-start', + }, ], }, { diff --git a/docs/.vitepress/routes/sidebar/guide.ts b/docs/.vitepress/routes/sidebar/guide.ts index d357d065..d3d37ffa 100644 --- a/docs/.vitepress/routes/sidebar/guide.ts +++ b/docs/.vitepress/routes/sidebar/guide.ts @@ -82,6 +82,20 @@ export const routes: DefaultTheme.SidebarItem[] = [ } ], }, + { + text: 'Hooks Provider', + base: '/guide/hooks', + items: [ + { + text: 'Quick Start', + link: '/quick-start', + }, + { + text: 'Examples', + link: '/examples', + } + ], + }, { text: 'Advanced', base: '/guide/advanced', diff --git a/docs/guide/hooks/examples.md b/docs/guide/hooks/examples.md new file mode 100644 index 00000000..a56ccda3 --- /dev/null +++ b/docs/guide/hooks/examples.md @@ -0,0 +1,89 @@ +# Hooks Provider examples + +## Basic `signIn` hook (body-based tokens) + +```ts +import { defineHooks } from '#imports' + +export default defineHooks({ + signIn: { + async createRequest({ credentials }) { + return { + path: '/auth/login', + request: { + method: 'post', + body: credentials, + }, + } + }, + + async onResponse(response) { + // Backend returns { access: 'xxx', refresh: 'yyy', user: {...} } + const body = response._data + return { + token: body?.access ?? undefined, + refreshToken: body?.refresh ?? undefined, + session: body?.user ?? undefined, + } + }, + }, + + getSession: { + async createRequest() { + return { + path: '/auth/profile', + request: { + method: 'get', + }, + } + }, + + async onResponse(response) { + return response._data ?? null + }, + }, +}) +``` + +## Tokens returned in headers + +```ts +export default defineHooks({ + signIn: { + createRequest: ({ credentials }) => ({ + path: '/auth/login', + request: { method: 'post', body: credentials }, + }), + + onResponse: (response) => { + const access = response.headers.get('x-access-token') + const refresh = response.headers.get('x-refresh-token') + // Don't return session — trigger a getSession call + return { token: access ?? undefined, refreshToken: refresh ?? undefined } + }, + }, + + getSession: { + createRequest: () => ({ path: '/auth/profile', request: { method: 'get' } }), + onResponse: (response) => response._data ?? null, + }, +}) +``` + +## Fully-hijacking the flow + +If your hook performs a redirect itself or sets cookies, you can stop the default flow by returning `false`: + +```ts +signIn: { + createRequest: (data) => ({ path: '/auth/login', request: { method: 'post', body: data.credentials } }), + async onResponse(response, authState, nuxt) { + // Handle everything yourself + authState.data.value = {} + authState.token.value = '' + // ... + + return false + } +} +``` diff --git a/docs/guide/hooks/quick-start.md b/docs/guide/hooks/quick-start.md new file mode 100644 index 00000000..c061d447 --- /dev/null +++ b/docs/guide/hooks/quick-start.md @@ -0,0 +1,166 @@ +# Hooks provider + +The Hooks Provider is an advanced and highly flexible provider intended for use with external authentication backends. + +Its main difference with Local Provider is that it does not ship any default implementation and instead relies on you providing an adapter for communicating with your backend. You get complete control over how requests are built and how responses are used. + +## Configuration + +In `nuxt.config.ts`: + +```ts +export default defineNuxtConfig({ + auth: { + provider: { + type: 'hooks', + adapter: '~/app/nuxt-auth-adapter.ts', + }, + }, +}) +```` + +The path should point to a file that exports an adapter implementing `Hooks`. + +## Adapter + +### Quick example + +Here's a quick minimal example of an adapter. Only `signIn` and `getSession` endpoints are required: + +```ts +export default defineHooksAdapter({ + signIn: { + createRequest: (credentials) => ({ + path: '/auth/login', + request: { method: 'post', body: credentials }, + }), + + onResponse: (response) => { + // Backend returns { access: 'xxx', refresh: 'yyy', user: {...} } + const body = response._data + return { + token: body?.access ?? undefined, + refreshToken: body?.refresh ?? undefined, + session: body?.user ?? undefined, + } + }, + }, + + getSession: { + createRequest: () => ({ + path: '/auth/profile', + request: { method: 'get' } + }), + onResponse: (response) => response._data ?? null, + }, +}) +``` + +### In detail + +A hooks provider expects the following adapter implementation for the auth endpoints: + +```ts +export interface HooksAdapter { + signIn: EndpointHooks + getSession: EndpointHooks + signOut?: EndpointHooks + signUp?: EndpointHooks + refresh?: EndpointHooks +} +``` + +Each `EndpointHooks` has two functions: `createRequest` and `onResponse`. + +#### `createRequest(data, authState, nuxt)` + +Prepare data for the fetch call. + +Must return either an object: + +```ts +{ + // Path to the endpoint + path: string, + // Request: body, headers, etc. + request: NitroFetchOptions +} +``` + +or `false` to stop execution (no network call will be performed). + +#### `onResponse(response, authState, nuxt)` + +Handle the response and optionally instruct the module how to update state. + +May return: +* `false` — stop further processing (module will not update auth state). +* `undefined` — proceed with default behaviour (e.g., the `signIn` flow will call `getSession` unless `signIn()` options say otherwise). +* `ResponseAccept` object — instruct the module what to set in `authState` (see below). +* Throw an `Error` to propagate a failure. + +The `response` argument is the [`ofetch` raw response](https://github.com/unjs/ofetch?tab=readme-ov-file#-access-to-raw-response) that the module uses as well. `response._data` usually contains parsed body. + +#### `ResponseAccept` shape (what `onResponse` can return) + +When `onResponse` returns an object (the `ResponseAccept`), it can contain: + +```ts +{ + token?: string | null, // set or clear the access token in authState + refreshToken?: string | null, // set or clear the refresh token in authState (if refresh is enabled) + session?: any | null // set or clear the session object (when provided, `getSession` will NOT be called) +} +``` + +When `token` is provided (not omitted and not `undefined`) the module will set `authState.token` (or clear it when `null`). +Same applies for `refreshToken` when refresh was enabled. + +When `session` is provided the module will use that session directly and will **not** call `getSession`. + +When the `onResponse` hook returns `undefined`, the module may call `getSession` (depending on the flow) to obtain the session. + +#### `authState` argument + +This argument gives you access to the state of the module, allowing to read or modify session data or tokens. + +#### `nuxt` argument + +This argument is provided for your convenience and to allow using Nuxt context for invoking other composables. See the [Nuxt documentation](https://nuxt.com/docs/4.x/api/composables/use-nuxt-app) for more information. + +### In short + +* `createRequest` builds and returns `{ path, request }`. The module will call `_fetchRaw(nuxt, path, request)`. + +* `onResponse` determines what the module should do next: + * `false` — stop everything (useful when the hook itself handled redirects, cookies or state changes). + * `undefined` — default behaviour (module may call `getSession`). + * `{ token?, refreshToken?, session? }` — module will set provided tokens/session in `authState`. + +## Pages + +Configure the path of the login-page that the user should be redirected to, when they try to access a protected page without being logged in. This page will also not be blocked by the global middleware. + +```ts +export default defineNuxtConfig({ + // previous configuration + auth: { + provider: { + type: 'hooks', + pages: { + login: '/login' + } + } + } +}) +``` + +## Some tips + +* When your backend uses **HTTP-only cookies** for session management, prefer returning `undefined` from `onResponse` — browsers will automatically include cookies; the module will call `getSession` to obtain the user object when needed. +* If your backend is cross-origin, remember to configure CORS and allow credentials: + + * `Access-Control-Allow-Credentials: true` + * `Access-Control-Allow-Origin: ` (cannot be `*` when credentials are used) +* The default hooks shipped with the module try to extract tokens using the configured token pointers (`token.signInResponseTokenPointer`) and headers. Use hooks only when you need more customization. + diff --git a/src/runtime/composables/hooks/hooks.ts b/src/runtime/composables/hooks/hooks.ts index a8b8d91a..cdf6859b 100644 --- a/src/runtime/composables/hooks/hooks.ts +++ b/src/runtime/composables/hooks/hooks.ts @@ -1,6 +1,6 @@ -import type { Hooks } from './types' +import type { HooksAdapter } from './types' -export function defineHooks(hooks: Hooks): Hooks { +export function defineHooksAdapter(hooks: HooksAdapter): HooksAdapter { return hooks } @@ -8,7 +8,7 @@ interface Session { // Data of users returned by `getSession` endpoint } -export default defineHooks({ +export default defineHooksAdapter({ signIn: { createRequest(credentials, authState, nuxt) { // todo diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts index 47e8caf8..a0aa92b4 100644 --- a/src/runtime/composables/hooks/types.ts +++ b/src/runtime/composables/hooks/types.ts @@ -82,7 +82,7 @@ export interface SignUpCreateRequestData { // TODO Use full UseAuthStateReturn, not the CommonUseAuthStateReturn -export interface Hooks { +export interface HooksAdapter { // Required endpoints signIn: EndpointHooks> getSession: EndpointHooks From 591ecb0d40f41d6b9e52010cf4a970e68ab39a1a Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 16:19:51 +0100 Subject: [PATCH 04/11] feat: add hooks playground and refine implementation --- playground-hooks/.gitignore | 5 + playground-hooks/app.vue | 49 ++ playground-hooks/config/AuthRefreshHandler.ts | 17 + playground-hooks/config/hooks.ts | 229 ++++++++ playground-hooks/nuxt.config.ts | 35 ++ playground-hooks/package.json | 29 ++ playground-hooks/pages/always-unprotected.vue | 11 + playground-hooks/pages/guest.vue | 16 + playground-hooks/pages/index.vue | 39 ++ playground-hooks/pages/login.vue | 33 ++ playground-hooks/pages/protected/globally.vue | 3 + playground-hooks/pages/protected/locally.vue | 12 + playground-hooks/pages/register.vue | 53 ++ playground-hooks/pages/signout.vue | 11 + playground-hooks/pages/with-caching.vue | 16 + playground-hooks/playwright.config.ts | 77 +++ playground-hooks/public/favicon.ico | Bin 0 -> 4286 bytes .../server/api/auth/login.post.ts | 25 + .../server/api/auth/logout.post.ts | 5 + .../server/api/auth/refresh.post.ts | 58 +++ .../server/api/auth/signup.post.ts | 24 + playground-hooks/server/api/auth/user.get.ts | 55 ++ playground-hooks/server/utils/session.ts | 181 +++++++ playground-hooks/tests/hooks.spec.ts | 93 ++++ playground-hooks/tsconfig.json | 4 + playground-hooks/vitest.config.ts | 7 + pnpm-lock.yaml | 493 ++---------------- src/module.ts | 45 +- src/runtime/composables/hooks/hooks.ts | 57 -- src/runtime/composables/hooks/types.ts | 18 +- src/runtime/composables/hooks/useAuth.ts | 143 +++-- src/runtime/composables/hooks/useAuthState.ts | 33 +- src/runtime/helpers.ts | 6 +- src/runtime/types.ts | 65 ++- 34 files changed, 1394 insertions(+), 553 deletions(-) create mode 100644 playground-hooks/.gitignore create mode 100644 playground-hooks/app.vue create mode 100644 playground-hooks/config/AuthRefreshHandler.ts create mode 100644 playground-hooks/config/hooks.ts create mode 100644 playground-hooks/nuxt.config.ts create mode 100644 playground-hooks/package.json create mode 100644 playground-hooks/pages/always-unprotected.vue create mode 100644 playground-hooks/pages/guest.vue create mode 100644 playground-hooks/pages/index.vue create mode 100644 playground-hooks/pages/login.vue create mode 100644 playground-hooks/pages/protected/globally.vue create mode 100644 playground-hooks/pages/protected/locally.vue create mode 100644 playground-hooks/pages/register.vue create mode 100644 playground-hooks/pages/signout.vue create mode 100644 playground-hooks/pages/with-caching.vue create mode 100644 playground-hooks/playwright.config.ts create mode 100644 playground-hooks/public/favicon.ico create mode 100644 playground-hooks/server/api/auth/login.post.ts create mode 100644 playground-hooks/server/api/auth/logout.post.ts create mode 100644 playground-hooks/server/api/auth/refresh.post.ts create mode 100644 playground-hooks/server/api/auth/signup.post.ts create mode 100644 playground-hooks/server/api/auth/user.get.ts create mode 100644 playground-hooks/server/utils/session.ts create mode 100644 playground-hooks/tests/hooks.spec.ts create mode 100644 playground-hooks/tsconfig.json create mode 100644 playground-hooks/vitest.config.ts delete mode 100644 src/runtime/composables/hooks/hooks.ts diff --git a/playground-hooks/.gitignore b/playground-hooks/.gitignore new file mode 100644 index 00000000..68c5d18f --- /dev/null +++ b/playground-hooks/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/playground-hooks/app.vue b/playground-hooks/app.vue new file mode 100644 index 00000000..1f770a6f --- /dev/null +++ b/playground-hooks/app.vue @@ -0,0 +1,49 @@ + + + diff --git a/playground-hooks/config/AuthRefreshHandler.ts b/playground-hooks/config/AuthRefreshHandler.ts new file mode 100644 index 00000000..037cf687 --- /dev/null +++ b/playground-hooks/config/AuthRefreshHandler.ts @@ -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() diff --git a/playground-hooks/config/hooks.ts b/playground-hooks/config/hooks.ts new file mode 100644 index 00000000..c0c58b6e --- /dev/null +++ b/playground-hooks/config/hooks.ts @@ -0,0 +1,229 @@ +import { array, object, jwt, optional, string, type z } from 'zod/mini' + +// TODO Export this from the `@sidebase/nuxt-auth' module +import type { HooksAdapter } from '../../src/runtime/composables/hooks/types' + +// TODO Export this from module +export function defineHooksAdapter(hooks: HooksAdapter): HooksAdapter { + return hooks +} + +/** Expected shape of the user object received from `getSession` demo endpoint */ +const sessionSchema = object({ + username: string(), + name: string(), + picture: optional(string()), + scope: array(string()), +}) +/** Demo user data */ +type Session = z.infer + +/** Expected response shape from `signIn` and `refresh` demo endpoints */ +const tokensSchema = object({ + accessToken: jwt(), + refreshToken: optional(jwt()), +}) + +/** Expected response shape from `signUp` demo endpoint */ +const signUpResponseSchema = object({ + user: sessionSchema, + tokens: tokensSchema, +}) + +export default defineHooksAdapter({ + // Required hooks: `signIn` and `getSession` + signIn: { + createRequest(signInData, _authState, _nuxt) { + // Call `/api/auth/login` with the method of POST + // and body containing credentials passed to `signIn` + return { + path: 'login', + request: { + method: 'post', + body: signInData.credentials, + } + } + }, + + onResponse(response, _authState, _nuxt) { + // Validate the response + const parsedResponse = tokensSchema.safeParse(response._data) + if (parsedResponse.success === false) { + // Returning `false` simply stops `signIn` execution, + // you can also throw an error depending on your usecase. + logError('Received wrong response from signIn', parsedResponse.error) + return false + } + + return { + token: parsedResponse.data.accessToken, + refreshToken: parsedResponse.data.refreshToken, + // You may also return the session directly if your backend + // additionally returns user data on `signIn` call. + // session: {}, + } + }, + }, + + getSession: { + createRequest(_getSessionOptions, authState, _nuxt) { + // Avoid calling `getSession` if no access token is present + if (authState.token.value === null) { + return false + } + + // Call `/api/auth/user` with the method of GET + // and access token added to `Authorization` header + return { + path: 'user', + request: { + method: 'get', + headers: { + Authorization: `Bearer ${authState.token.value}`, + }, + } + } + }, + + onResponse(response, _authState, _nuxt) { + // Validate the response + const parsedResponse = sessionSchema.safeParse(response._data) + if (parsedResponse.success === false) { + // Returning `false` simply stops `getSession` execution, + // you can also throw an error depending on your usecase. + logError('Received wrong response from getSession', parsedResponse.error) + return false + } + + return { + session: parsedResponse.data, + // You may also return the tokens if your backend + // additionally returns tokens on `getSession` call. + // token: '', + // refreshToken: '', + } + } + }, + + // Optional hooks + signUp: { + createRequest(signUpData, _authState, _nuxt) { + // Call `/api/auth/signup` with the method of POST, + // and credentials added to body + return { + path: 'signup', + request: { + method: 'post', + body: signUpData.credentials, + } + } + }, + + onResponse(response, _authState, _nuxt) { + // Validate the response + const parsedResponse = signUpResponseSchema.safeParse(response._data) + if (parsedResponse.success === false) { + // Returning `false` simply stops `signUp` execution, + // you can also throw an error depending on your usecase. + logError('Received wrong response from signUp', parsedResponse.error) + return false + } + + return { + token: parsedResponse.data.tokens.accessToken, + refreshToken: parsedResponse.data.tokens.refreshToken, + session: parsedResponse.data.user, + } + }, + }, + + refresh: { + createRequest(_getSessionOptions, authState, _nuxt) { + // Our demo backend requires both access and refresh tokens + // for the `refresh` call. If at least one of the tokens is + // not present, we reset authentication state and avoid calling `refresh`. + // Note that your implementation may differ. + if (authState.token.value === null || authState.refreshToken.value === null) { + authState.token.value = null + authState.refreshToken.value = null + authState.data.value = null + return false + } + + // Call `/api/auth/refresh` with the method of POST, + // access token added to `Authorization` header + // and refresh token added to body + return { + path: 'refresh', + request: { + method: 'post', + headers: { + Authorization: `Bearer ${authState.token.value}`, + }, + body: { + refreshToken: authState.refreshToken.value, + }, + } + } + }, + + onResponse(response, _authState, _nuxt) { + // Validate the response + // Note: for convenience purposes this demo was setup to return the same shape from + // `refresh` as from `signIn` + const parsedResponse = tokensSchema.safeParse(response._data) + if (parsedResponse.success === false) { + // Returning `false` simply stops `signIn` execution, + // you can also throw an error depending on your usecase. + logError('Received wrong response from refresh', parsedResponse.error) + return false + } + + return { + token: parsedResponse.data.accessToken, + refreshToken: parsedResponse.data.refreshToken, + // You may also return the session directly if your backend + // additionally returns user data on `refresh` call. + // session: {}, + } + }, + }, + + signOut: { + createRequest(_signOutOptions, authState, _nuxt) { + // Avoid calling `signOut` if either access or refresh token is not present, + // reset the authentication state manually + if (authState.token.value === null || authState.refreshToken.value === null) { + authState.token.value = null + authState.refreshToken.value = null + authState.data.value = null + return false + } + + // Call `/api/auth/logout` with the method of POST, + // access token added to `Authorization` header + // and refresh token added to body + return { + path: 'logout', + request: { + method: 'post', + headers: { + Authorization: `Bearer ${authState.token.value}`, + }, + body: { + refreshToken: authState.refreshToken.value, + }, + } + } + }, + + onResponse(_response, _authState, _nuxt) { + // Return `undefined` to reset the authentication state + return undefined + }, + }, +}) + +function logError(...args: unknown[]) { + import.meta.dev && console.error(...args) +} diff --git a/playground-hooks/nuxt.config.ts b/playground-hooks/nuxt.config.ts new file mode 100644 index 00000000..41c7dfc0 --- /dev/null +++ b/playground-hooks/nuxt.config.ts @@ -0,0 +1,35 @@ +export default defineNuxtConfig({ + compatibilityDate: '2024-04-03', + modules: ['../src/module.ts'], + build: { + transpile: ['jsonwebtoken'] + }, + auth: { + provider: { + type: 'hooks', + adapter: '~/config/hooks.ts', + refresh: { + isEnabled: true, + }, + }, + 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: 30000, + // Custom refresh handler - uncomment to use + // handler: './config/AuthRefreshHandler' + }, + globalAppMiddleware: { + isEnabled: true + } + }, + routeRules: { + '/with-caching': { + swr: 86400000, + auth: { + disableServerSideAuth: true + } + } + } +}) diff --git a/playground-hooks/package.json b/playground-hooks/package.json new file mode 100644 index 00000000..cefb0755 --- /dev/null +++ b/playground-hooks/package.json @@ -0,0 +1,29 @@ +{ + "private": true, + "name": "nuxt-auth-playground-local", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "dev": "nuxi prepare && nuxi dev", + "build": "nuxi build", + "start": "nuxi preview", + "generate": "nuxi generate", + "postinstall": "nuxt prepare", + "test:e2e": "vitest" + }, + "dependencies": { + "jsonwebtoken": "^9.0.2", + "zod": "^4.2.1" + }, + "devDependencies": { + "@nuxt/test-utils": "^3.19.2", + "@playwright/test": "^1.54.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.19.6", + "@vue/test-utils": "^2.4.6", + "nuxt": "^3.17.6", + "typescript": "^5.8.3", + "vitest": "^3.2.4", + "vue-tsc": "^2.2.12" + } +} diff --git a/playground-hooks/pages/always-unprotected.vue b/playground-hooks/pages/always-unprotected.vue new file mode 100644 index 00000000..c088043c --- /dev/null +++ b/playground-hooks/pages/always-unprotected.vue @@ -0,0 +1,11 @@ + + + diff --git a/playground-hooks/pages/guest.vue b/playground-hooks/pages/guest.vue new file mode 100644 index 00000000..38b5d659 --- /dev/null +++ b/playground-hooks/pages/guest.vue @@ -0,0 +1,16 @@ + + + diff --git a/playground-hooks/pages/index.vue b/playground-hooks/pages/index.vue new file mode 100644 index 00000000..a87a48b9 --- /dev/null +++ b/playground-hooks/pages/index.vue @@ -0,0 +1,39 @@ + + + diff --git a/playground-hooks/pages/login.vue b/playground-hooks/pages/login.vue new file mode 100644 index 00000000..a9786e3b --- /dev/null +++ b/playground-hooks/pages/login.vue @@ -0,0 +1,33 @@ + + + diff --git a/playground-hooks/pages/protected/globally.vue b/playground-hooks/pages/protected/globally.vue new file mode 100644 index 00000000..ed51ab4a --- /dev/null +++ b/playground-hooks/pages/protected/globally.vue @@ -0,0 +1,3 @@ + diff --git a/playground-hooks/pages/protected/locally.vue b/playground-hooks/pages/protected/locally.vue new file mode 100644 index 00000000..dd3dbacf --- /dev/null +++ b/playground-hooks/pages/protected/locally.vue @@ -0,0 +1,12 @@ + + + diff --git a/playground-hooks/pages/register.vue b/playground-hooks/pages/register.vue new file mode 100644 index 00000000..df8c3cc2 --- /dev/null +++ b/playground-hooks/pages/register.vue @@ -0,0 +1,53 @@ + + + diff --git a/playground-hooks/pages/signout.vue b/playground-hooks/pages/signout.vue new file mode 100644 index 00000000..cedbbf08 --- /dev/null +++ b/playground-hooks/pages/signout.vue @@ -0,0 +1,11 @@ + + + diff --git a/playground-hooks/pages/with-caching.vue b/playground-hooks/pages/with-caching.vue new file mode 100644 index 00000000..0d7166fc --- /dev/null +++ b/playground-hooks/pages/with-caching.vue @@ -0,0 +1,16 @@ + + + diff --git a/playground-hooks/playwright.config.ts b/playground-hooks/playwright.config.ts new file mode 100644 index 00000000..ea3be7c0 --- /dev/null +++ b/playground-hooks/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + } + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] } + // } + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ] + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}) diff --git a/playground-hooks/public/favicon.ico b/playground-hooks/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..18993ad91cfd43e03b074dd0b5cc3f37ab38e49c GIT binary patch literal 4286 zcmeHLOKuuL5PjK%MHWVi6lD zOGiREbCw`xmFozJ^aNatJY>w+g ze6a2@u~m#^BZm@8wco9#Crlli0uLb^3E$t2-WIc^#(?t)*@`UpuofJ(Uyh@F>b3Ph z$D^m8Xq~pTkGJ4Q`Q2)te3mgkWYZ^Ijq|hkiP^9`De={bQQ%heZC$QU2UpP(-tbl8 zPWD2abEew;oat@w`uP3J^YpsgT%~jT(Dk%oU}sa$7|n6hBjDj`+I;RX(>)%lm_7N{+B7Mu%H?422lE%MBJH!!YTN2oT7xr>>N-8OF$C&qU^ z>vLsa{$0X%q1fjOe3P1mCv#lN{xQ4_*HCSAZjTb1`}mlc+9rl8$B3OP%VT@mch_~G z7Y+4b{r>9e=M+7vSI;BgB?ryZDY4m>&wcHSn81VH1N~`0gvwH{ z8dv#hG|OK`>1;j7tM#B)Z7zDN?{6=dUal}$e { + const result = credentialsSchema.safeParse(await readBody(event)) + if (!result.success) { + throw createError({ + statusCode: 403, + message: 'Unauthorized, hint: try `hunter2` as password' + }) + } + + // Emulate successful login + const user = await getUser(result.data.username) + + // Sign the tokens + const tokens = await createUserTokens(user) + + return tokens +}) diff --git a/playground-hooks/server/api/auth/logout.post.ts b/playground-hooks/server/api/auth/logout.post.ts new file mode 100644 index 00000000..94c9d143 --- /dev/null +++ b/playground-hooks/server/api/auth/logout.post.ts @@ -0,0 +1,5 @@ +import { eventHandler } from 'h3' + +// We are not actually clearing any state here since this is a demo endpoint. +// Remember to handle the user signout properly in real applications. +export default eventHandler(() => ({ status: 'OK' })) diff --git a/playground-hooks/server/api/auth/refresh.post.ts b/playground-hooks/server/api/auth/refresh.post.ts new file mode 100644 index 00000000..5c971bbc --- /dev/null +++ b/playground-hooks/server/api/auth/refresh.post.ts @@ -0,0 +1,58 @@ +import { createError, eventHandler, getRequestHeader, readBody } from 'h3' +import { checkUserTokens, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser, refreshUserAccessToken } from '~/server/utils/session' + +/* + * 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, + message: 'Unauthorized, no refreshToken or no Authorization header' + }) + } + + // Verify + const decoded = decodeToken(refreshToken) + if (!decoded) { + throw createError({ + statusCode: 401, + message: 'Unauthorized, refreshToken can\'t be verified' + }) + } + + // Get the helper (only for demo, use a DB in your implementation) + const userTokens = getTokensByUser(decoded.username) + if (!userTokens) { + throw createError({ + statusCode: 401, + message: 'Unauthorized, user is not logged in' + }) + } + + // Check against known token + const requestAccessToken = extractTokenFromAuthorizationHeader(authorizationHeader) + const tokensValidityCheck = checkUserTokens(userTokens, requestAccessToken, refreshToken) + if (!tokensValidityCheck.valid) { + console.log({ + msg: 'Tokens mismatch', + knownAccessToken: tokensValidityCheck.knownAccessToken, + requestAccessToken + }) + throw createError({ + statusCode: 401, + message: 'Tokens mismatch - this is not good' + }) + } + + // Call the token refresh logic + const tokens = await refreshUserAccessToken(userTokens, refreshToken) + + return tokens +}) diff --git a/playground-hooks/server/api/auth/signup.post.ts b/playground-hooks/server/api/auth/signup.post.ts new file mode 100644 index 00000000..fa965ca5 --- /dev/null +++ b/playground-hooks/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, + message: `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, + tokens, + } +}) diff --git a/playground-hooks/server/api/auth/user.get.ts b/playground-hooks/server/api/auth/user.get.ts new file mode 100644 index 00000000..7ce7abea --- /dev/null +++ b/playground-hooks/server/api/auth/user.get.ts @@ -0,0 +1,55 @@ +import { createError, eventHandler, getRequestHeader } from 'h3' +import { checkUserAccessToken, decodeToken, extractTokenFromAuthorizationHeader, getTokensByUser } from '~/server/utils/session' +import type { JwtPayload } from '~/server/utils/session' + +export default eventHandler((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 + try { + const decodeTokenResult = decodeToken(requestAccessToken) + + if (!decodeTokenResult) { + throw new Error('Expected decoded JwtPayload to be non-empty') + } + decoded = decodeTokenResult + } + catch (error) { + console.error({ + msg: 'Login failed. Here\'s the raw error:', + error + }) + throw createError({ statusCode: 403, message: '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, + message: 'User not found' + }) + } + + // Check against known token + const tokensValidityCheck = checkUserAccessToken(userTokens, requestAccessToken) + if (!tokensValidityCheck.valid) { + throw createError({ + statusCode: 401, + message: 'Unauthorized, user is not logged in' + }) + } + + // All checks successful + const { username, name, picture, scope } = decoded + return { + username, + name, + picture, + scope + } +}) diff --git a/playground-hooks/server/utils/session.ts b/playground-hooks/server/utils/session.ts new file mode 100644 index 00000000..f4fa852d --- /dev/null +++ b/playground-hooks/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' + +/** 5 minutes */ +const ACCESS_TOKEN_TTL = 300 + +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-hooks/tests/hooks.spec.ts b/playground-hooks/tests/hooks.spec.ts new file mode 100644 index 00000000..d576da42 --- /dev/null +++ b/playground-hooks/tests/hooks.spec.ts @@ -0,0 +1,93 @@ +import { createPage, setup } from '@nuxt/test-utils/e2e' +import { expect as playwrightExpect } from '@nuxt/test-utils/playwright' +import { describe, it } from 'vitest' + +const STATUS_AUTHENTICATED = 'authenticated' +const STATUS_UNAUTHENTICATED = 'unauthenticated' + +describe('local Provider', async () => { + await setup({ + runner: 'vitest', + browser: true + }) + + it('load, sign in, reload, refresh, sign out', async () => { + const page = await createPage('/') + const [ + usernameInput, + passwordInput, + submitButton, + status, + signoutButton, + refreshRequiredFalseButton, + refreshRequiredTrueButton + ] = await Promise.all([ + page.getByTestId('username'), + page.getByTestId('password'), + page.getByTestId('submit'), + page.getByTestId('status'), + page.getByTestId('signout'), + page.getByTestId('refresh-required-false'), + page.getByTestId('refresh-required-true') + ]) + + await playwrightExpect(status).toHaveText(STATUS_UNAUTHENTICATED) + + await usernameInput.fill('hunter') + await passwordInput.fill('hunter2') + + // Click button and wait for API to finish + const responsePromise = page.waitForResponse(/\/api\/auth\/login/) + await submitButton.click() + await responsePromise + + await playwrightExpect(status).toHaveText(STATUS_AUTHENTICATED) + + // Ensure that we are still authenticated after page refresh + await page.reload() + await playwrightExpect(status).toHaveText(STATUS_AUTHENTICATED) + + // Refresh (required: false), status should not change + await refreshRequiredFalseButton.click() + await playwrightExpect(status).toHaveText(STATUS_AUTHENTICATED) + + // Refresh (required: true), status should not change + await refreshRequiredTrueButton.click() + await playwrightExpect(status).toHaveText(STATUS_AUTHENTICATED) + + // Sign out, status should change + 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/playground-hooks/tsconfig.json b/playground-hooks/tsconfig.json new file mode 100644 index 00000000..1dc1eb73 --- /dev/null +++ b/playground-hooks/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./.nuxt/tsconfig.json", + "exclude": ["../docs"] +} diff --git a/playground-hooks/vitest.config.ts b/playground-hooks/vitest.config.ts new file mode 100644 index 00000000..843ed788 --- /dev/null +++ b/playground-hooks/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/*.spec.ts'] + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2458b241..1eb99394 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,7 +56,7 @@ importers: version: 9.30.1(jiti@2.4.2) nuxt: specifier: ^3.17.6 - version: 3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0) + version: 3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0) ofetch: specifier: ^1.4.1 version: 1.4.1 @@ -83,10 +83,47 @@ importers: devDependencies: nuxt: specifier: ^3.17.6 - version: 3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0) + version: 3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0) + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12(typescript@5.8.3) + + playground-hooks: + dependencies: + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + zod: + specifier: ^4.2.1 + version: 4.2.1 + devDependencies: + '@nuxt/test-utils': + specifier: ^3.19.2 + version: 3.19.2(@playwright/test@1.54.0)(@vue/test-utils@2.4.6)(magicast@0.3.5)(playwright-core@1.54.0)(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) + '@playwright/test': + specifier: ^1.54.0 + version: 1.54.0 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/node': + specifier: ^20.19.6 + version: 20.19.6 + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.6 + nuxt: + specifier: ^3.17.6 + version: 3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0) typescript: specifier: ^5.8.3 version: 5.8.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) vue-tsc: specifier: ^2.2.12 version: 2.2.12(typescript@5.8.3) @@ -2686,6 +2723,9 @@ packages: caniuse-lite@1.0.30001727: resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} + caniuse-lite@1.0.30001760: + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6722,6 +6762,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6867,7 +6910,7 @@ snapshots: eslint-plugin-regexp: 2.9.0(eslint@9.30.1(jiti@2.4.2)) eslint-plugin-toml: 0.12.0(eslint@9.30.1(jiti@2.4.2)) eslint-plugin-unicorn: 59.0.1(eslint@9.30.1(jiti@2.4.2)) - eslint-plugin-unused-imports: 4.1.4(@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@6.21.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)) + eslint-plugin-unused-imports: 4.1.4(@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)) eslint-plugin-vue: 10.3.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(vue-eslint-parser@10.2.0(eslint@9.30.1(jiti@2.4.2))) eslint-plugin-yml: 1.18.0(eslint@9.30.1(jiti@2.4.2)) eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.17)(eslint@9.30.1(jiti@2.4.2)) @@ -7838,22 +7881,6 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@2.6.2(magicast@0.3.5)(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))': - dependencies: - '@nuxt/kit': 3.17.6(magicast@0.3.5) - execa: 8.0.1 - vite: 5.4.19(@types/node@20.19.6)(terser@5.30.3) - transitivePeerDependencies: - - magicast - - '@nuxt/devtools-kit@2.6.2(magicast@0.3.5)(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))': - dependencies: - '@nuxt/kit': 3.17.6(magicast@0.3.5) - execa: 8.0.1 - vite: 6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) - transitivePeerDependencies: - - magicast - '@nuxt/devtools-kit@2.6.2(magicast@0.3.5)(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))': dependencies: '@nuxt/kit': 3.17.6(magicast@0.3.5) @@ -7873,88 +7900,6 @@ snapshots: prompts: 2.4.2 semver: 7.7.2 - '@nuxt/devtools@2.6.2(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue@3.5.17(typescript@5.8.3))': - dependencies: - '@nuxt/devtools-kit': 2.6.2(magicast@0.3.5)(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)) - '@nuxt/devtools-wizard': 2.6.2 - '@nuxt/kit': 3.17.6(magicast@0.3.5) - '@vue/devtools-core': 7.7.7(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue@3.5.17(typescript@5.8.3)) - '@vue/devtools-kit': 7.7.7 - birpc: 2.4.0 - consola: 3.4.2 - destr: 2.0.5 - error-stack-parser-es: 1.0.5 - execa: 8.0.1 - fast-npm-meta: 0.4.4 - get-port-please: 3.1.2 - hookable: 5.5.3 - image-meta: 0.2.1 - is-installed-globally: 1.0.0 - launch-editor: 2.10.0 - local-pkg: 1.1.1 - magicast: 0.3.5 - nypm: 0.6.0 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 1.0.0 - pkg-types: 2.2.0 - semver: 7.7.2 - simple-git: 3.28.0 - sirv: 3.0.1 - structured-clone-es: 1.0.0 - tinyglobby: 0.2.14 - vite: 5.4.19(@types/node@20.19.6)(terser@5.30.3) - vite-plugin-inspect: 11.3.0(@nuxt/kit@3.17.6(magicast@0.3.5))(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)) - vite-plugin-vue-tracer: 1.0.0(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue@3.5.17(typescript@5.8.3)) - which: 5.0.0 - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - vue - - '@nuxt/devtools@2.6.2(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3))': - dependencies: - '@nuxt/devtools-kit': 2.6.2(magicast@0.3.5)(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) - '@nuxt/devtools-wizard': 2.6.2 - '@nuxt/kit': 3.17.6(magicast@0.3.5) - '@vue/devtools-core': 7.7.7(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3)) - '@vue/devtools-kit': 7.7.7 - birpc: 2.4.0 - consola: 3.4.2 - destr: 2.0.5 - error-stack-parser-es: 1.0.5 - execa: 8.0.1 - fast-npm-meta: 0.4.4 - get-port-please: 3.1.2 - hookable: 5.5.3 - image-meta: 0.2.1 - is-installed-globally: 1.0.0 - launch-editor: 2.10.0 - local-pkg: 1.1.1 - magicast: 0.3.5 - nypm: 0.6.0 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 1.0.0 - pkg-types: 2.2.0 - semver: 7.7.2 - simple-git: 3.28.0 - sirv: 3.0.1 - structured-clone-es: 1.0.0 - tinyglobby: 0.2.14 - vite: 6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) - vite-plugin-inspect: 11.3.0(@nuxt/kit@3.17.6(magicast@0.3.5))(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) - vite-plugin-vue-tracer: 1.0.0(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3)) - which: 5.0.0 - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - vue - '@nuxt/devtools@2.6.2(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3))': dependencies: '@nuxt/devtools-kit': 2.6.2(magicast@0.3.5)(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) @@ -9028,30 +8973,6 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.7 - '@vue/devtools-core@7.7.7(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue@3.5.17(typescript@5.8.3))': - dependencies: - '@vue/devtools-kit': 7.7.7 - '@vue/devtools-shared': 7.7.7 - mitt: 3.0.1 - nanoid: 5.1.5 - pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)) - vue: 3.5.17(typescript@5.8.3) - transitivePeerDependencies: - - vite - - '@vue/devtools-core@7.7.7(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3))': - dependencies: - '@vue/devtools-kit': 7.7.7 - '@vue/devtools-shared': 7.7.7 - mitt: 3.0.1 - nanoid: 5.1.5 - pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) - vue: 3.5.17(typescript@5.8.3) - transitivePeerDependencies: - - vite - '@vue/devtools-core@7.7.7(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3))': dependencies: '@vue/devtools-kit': 7.7.7 @@ -9465,6 +9386,8 @@ snapshots: caniuse-lite@1.0.30001727: {} + caniuse-lite@1.0.30001760: {} + ccount@2.0.1: {} chai@5.2.1: @@ -10487,7 +10410,7 @@ snapshots: semver: 7.7.2 strip-indent: 4.0.0 - eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@6.21.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)): + eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)): dependencies: eslint: 9.30.1(jiti@2.4.2) optionalDependencies: @@ -12008,7 +11931,7 @@ snapshots: '@next/env': 13.5.11 '@swc/helpers': 0.5.2 busboy: 1.6.0 - caniuse-lite: 1.0.30001727 + caniuse-lite: 1.0.30001760 postcss: 8.4.31 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -12202,246 +12125,6 @@ snapshots: nuxi@3.16.0: {} - nuxt@3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0): - dependencies: - '@nuxt/cli': 3.25.1(magicast@0.3.5) - '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 2.6.2(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue@3.5.17(typescript@5.8.3)) - '@nuxt/kit': 3.17.6(magicast@0.3.5) - '@nuxt/schema': 3.17.6 - '@nuxt/telemetry': 2.6.6(magicast@0.3.5) - '@nuxt/vite-builder': 3.17.6(@types/node@20.19.6)(eslint@9.30.1(jiti@2.4.2))(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vue-tsc@2.2.12(typescript@5.8.3))(vue@3.5.17(typescript@5.8.3))(yaml@2.8.0) - '@unhead/vue': 2.0.12(vue@3.5.17(typescript@5.8.3)) - '@vue/shared': 3.5.17 - c12: 3.0.4(magicast@0.3.5) - chokidar: 4.0.3 - compatx: 0.2.0 - consola: 3.4.2 - cookie-es: 2.0.0 - defu: 6.1.4 - destr: 2.0.5 - devalue: 5.1.1 - errx: 0.1.0 - esbuild: 0.25.6 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - exsolve: 1.0.7 - h3: 1.15.3 - hookable: 5.5.3 - ignore: 7.0.5 - impound: 1.0.0 - jiti: 2.4.2 - klona: 2.0.6 - knitwork: 1.2.0 - magic-string: 0.30.17 - mlly: 1.7.4 - mocked-exports: 0.1.1 - nanotar: 0.2.0 - nitropack: 2.11.13(@netlify/blobs@8.2.0)(encoding@0.1.13) - nypm: 0.6.0 - ofetch: 1.4.1 - ohash: 2.0.11 - on-change: 5.0.1 - oxc-parser: 0.75.1 - pathe: 2.0.3 - perfect-debounce: 1.0.0 - pkg-types: 2.2.0 - radix3: 1.1.2 - scule: 1.3.0 - semver: 7.7.2 - std-env: 3.9.0 - strip-literal: 3.0.0 - tinyglobby: 0.2.14 - ufo: 1.6.1 - ultrahtml: 1.6.0 - uncrypto: 0.1.3 - unctx: 2.4.1 - unimport: 5.1.0 - unplugin: 2.3.5 - unplugin-vue-router: 0.14.0(@vue/compiler-sfc@3.5.17)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) - unstorage: 1.16.0(@netlify/blobs@8.2.0)(db0@0.3.2)(ioredis@5.6.1) - untyped: 2.0.0 - vue: 3.5.17(typescript@5.8.3) - vue-bundle-renderer: 2.1.1 - vue-devtools-stub: 0.1.0 - vue-router: 4.5.1(vue@3.5.17(typescript@5.8.3)) - optionalDependencies: - '@parcel/watcher': 2.4.1 - '@types/node': 20.19.6 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@biomejs/biome' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/kv' - - '@vue/compiler-sfc' - - aws4fetch - - better-sqlite3 - - bufferutil - - db0 - - drizzle-orm - - encoding - - eslint - - idb-keyval - - ioredis - - less - - lightningcss - - magicast - - meow - - mysql2 - - optionator - - rolldown - - rollup - - sass - - sass-embedded - - sqlite3 - - stylelint - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - utf-8-validate - - vite - - vls - - vti - - vue-tsc - - xml2js - - yaml - - nuxt@3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0): - dependencies: - '@nuxt/cli': 3.25.1(magicast@0.3.5) - '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 2.6.2(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3)) - '@nuxt/kit': 3.17.6(magicast@0.3.5) - '@nuxt/schema': 3.17.6 - '@nuxt/telemetry': 2.6.6(magicast@0.3.5) - '@nuxt/vite-builder': 3.17.6(@types/node@20.19.6)(eslint@9.30.1(jiti@2.4.2))(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vue-tsc@2.2.12(typescript@5.8.3))(vue@3.5.17(typescript@5.8.3))(yaml@2.8.0) - '@unhead/vue': 2.0.12(vue@3.5.17(typescript@5.8.3)) - '@vue/shared': 3.5.17 - c12: 3.0.4(magicast@0.3.5) - chokidar: 4.0.3 - compatx: 0.2.0 - consola: 3.4.2 - cookie-es: 2.0.0 - defu: 6.1.4 - destr: 2.0.5 - devalue: 5.1.1 - errx: 0.1.0 - esbuild: 0.25.6 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - exsolve: 1.0.7 - h3: 1.15.3 - hookable: 5.5.3 - ignore: 7.0.5 - impound: 1.0.0 - jiti: 2.4.2 - klona: 2.0.6 - knitwork: 1.2.0 - magic-string: 0.30.17 - mlly: 1.7.4 - mocked-exports: 0.1.1 - nanotar: 0.2.0 - nitropack: 2.11.13(@netlify/blobs@8.2.0)(encoding@0.1.13) - nypm: 0.6.0 - ofetch: 1.4.1 - ohash: 2.0.11 - on-change: 5.0.1 - oxc-parser: 0.75.1 - pathe: 2.0.3 - perfect-debounce: 1.0.0 - pkg-types: 2.2.0 - radix3: 1.1.2 - scule: 1.3.0 - semver: 7.7.2 - std-env: 3.9.0 - strip-literal: 3.0.0 - tinyglobby: 0.2.14 - ufo: 1.6.1 - ultrahtml: 1.6.0 - uncrypto: 0.1.3 - unctx: 2.4.1 - unimport: 5.1.0 - unplugin: 2.3.5 - unplugin-vue-router: 0.14.0(@vue/compiler-sfc@3.5.17)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) - unstorage: 1.16.0(@netlify/blobs@8.2.0)(db0@0.3.2)(ioredis@5.6.1) - untyped: 2.0.0 - vue: 3.5.17(typescript@5.8.3) - vue-bundle-renderer: 2.1.1 - vue-devtools-stub: 0.1.0 - vue-router: 4.5.1(vue@3.5.17(typescript@5.8.3)) - optionalDependencies: - '@parcel/watcher': 2.4.1 - '@types/node': 20.19.6 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@biomejs/biome' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/kv' - - '@vue/compiler-sfc' - - aws4fetch - - better-sqlite3 - - bufferutil - - db0 - - drizzle-orm - - encoding - - eslint - - idb-keyval - - ioredis - - less - - lightningcss - - magicast - - meow - - mysql2 - - optionator - - rolldown - - rollup - - sass - - sass-embedded - - sqlite3 - - stylelint - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - utf-8-validate - - vite - - vls - - vti - - vue-tsc - - xml2js - - yaml - nuxt@3.17.6(@netlify/blobs@8.2.0)(@parcel/watcher@2.4.1)(@types/node@20.19.6)(@vue/compiler-sfc@3.5.17)(db0@0.3.2)(encoding@0.1.13)(eslint@9.30.1(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(rollup@4.44.2)(terser@5.30.3)(typescript@5.8.3)(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue-tsc@2.2.12(typescript@5.8.3))(yaml@2.8.0): dependencies: '@nuxt/cli': 3.25.1(magicast@0.3.5) @@ -14076,32 +13759,12 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-dev-rpc@1.1.0(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)): - dependencies: - birpc: 2.4.0 - vite: 5.4.19(@types/node@20.19.6)(terser@5.30.3) - vite-hot-client: 2.1.0(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)) - - vite-dev-rpc@1.1.0(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)): - dependencies: - birpc: 2.4.0 - vite: 6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) - vite-hot-client: 2.1.0(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) - vite-dev-rpc@1.1.0(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)): dependencies: birpc: 2.4.0 vite: 7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) vite-hot-client: 2.1.0(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) - vite-hot-client@2.1.0(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)): - dependencies: - vite: 5.4.19(@types/node@20.19.6)(terser@5.30.3) - - vite-hot-client@2.1.0(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)): - dependencies: - vite: 6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) - vite-hot-client@2.1.0(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)): dependencies: vite: 7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) @@ -14144,40 +13807,6 @@ snapshots: typescript: 5.8.3 vue-tsc: 2.2.12(typescript@5.8.3) - vite-plugin-inspect@11.3.0(@nuxt/kit@3.17.6(magicast@0.3.5))(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)): - dependencies: - ansis: 4.1.0 - debug: 4.4.1 - error-stack-parser-es: 1.0.5 - ohash: 2.0.11 - open: 10.1.2 - perfect-debounce: 1.0.0 - sirv: 3.0.1 - unplugin-utils: 0.2.4 - vite: 5.4.19(@types/node@20.19.6)(terser@5.30.3) - vite-dev-rpc: 1.1.0(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3)) - optionalDependencies: - '@nuxt/kit': 3.17.6(magicast@0.3.5) - transitivePeerDependencies: - - supports-color - - vite-plugin-inspect@11.3.0(@nuxt/kit@3.17.6(magicast@0.3.5))(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)): - dependencies: - ansis: 4.1.0 - debug: 4.4.1 - error-stack-parser-es: 1.0.5 - ohash: 2.0.11 - open: 10.1.2 - perfect-debounce: 1.0.0 - sirv: 3.0.1 - unplugin-utils: 0.2.4 - vite: 6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) - vite-dev-rpc: 1.1.0(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)) - optionalDependencies: - '@nuxt/kit': 3.17.6(magicast@0.3.5) - transitivePeerDependencies: - - supports-color - vite-plugin-inspect@11.3.0(@nuxt/kit@3.17.6(magicast@0.3.5))(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0)): dependencies: ansis: 4.1.0 @@ -14195,26 +13824,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.0.0(vite@5.4.19(@types/node@20.19.6)(terser@5.30.3))(vue@3.5.17(typescript@5.8.3)): - dependencies: - estree-walker: 3.0.3 - exsolve: 1.0.7 - magic-string: 0.30.17 - pathe: 2.0.3 - source-map-js: 1.2.1 - vite: 5.4.19(@types/node@20.19.6)(terser@5.30.3) - vue: 3.5.17(typescript@5.8.3) - - vite-plugin-vue-tracer@1.0.0(vite@6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3)): - dependencies: - estree-walker: 3.0.3 - exsolve: 1.0.7 - magic-string: 0.30.17 - pathe: 2.0.3 - source-map-js: 1.2.1 - vite: 6.3.5(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0) - vue: 3.5.17(typescript@5.8.3) - vite-plugin-vue-tracer@1.0.0(vite@7.0.4(@types/node@20.19.6)(jiti@2.4.2)(terser@5.30.3)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3)): dependencies: estree-walker: 3.0.3 @@ -14607,4 +14216,6 @@ snapshots: zod@3.25.76: {} + zod@4.2.1: {} + zwitch@2.0.4: {} diff --git a/src/module.ts b/src/module.ts index 658173c3..b29f3a33 100644 --- a/src/module.ts +++ b/src/module.ts @@ -17,6 +17,7 @@ import type { NuxtModule } from 'nuxt/schema' import { isProduction } from './runtime/helpers' import type { AuthProviders, + CookieOptions, ModuleOptions, ModuleOptionsNormalized, RefreshHandler, @@ -96,7 +97,31 @@ const defaultsByBackend: { trustHost: false, defaultProvider: '', // this satisfies Required and also gets caught at `!provider` check addDefaultCallbackUrl: true - } + }, + + hooks: { + type: 'hooks', + adapter: '', // this satisfies Required and also gets caught at `!adapter` check + token: { + // TODO Remove `as Required` cast and allow omitting properties in defaults + internalCookie: { + name: 'auth.token', + maxAge: 60 * 30, // 30 minutes + sameSite: 'lax', + } as Required + }, + refresh: { + isEnabled: false, + token: { + // TODO Remove `as Required` cast and allow omitting properties in defaults + internalCookie: { + name: 'auth.refresh-token', + maxAge: 60 * 60 * 24 * 7, // 7 days + sameSite: 'lax', + } as Required + } + } + }, } const PACKAGE_NAME = 'sidebase-auth' @@ -240,6 +265,24 @@ export default defineNuxtModule({ from: generatedRefreshHandlerPath }]) + // 5.3. Register a virtual import for the adapter + if (options.provider.type === 'hooks') { + const implementation = options.provider.adapter + if (!implementation) { + throw new Error( + 'Adapter implementation is required for the Hooks provider' + ) + } + + addTemplate({ + filename: 'nuxt-auth/hooks-adapter.ts', + async getContents() { + const path = (await resolvePath(implementation)).replace(/\.ts$/, '') + return `export { default } from '${path}'` + } + }) + } + // 6. Register middleware for autocomplete in definePageMeta addRouteMiddleware({ name: MIDDLEWARE_NAME, diff --git a/src/runtime/composables/hooks/hooks.ts b/src/runtime/composables/hooks/hooks.ts deleted file mode 100644 index cdf6859b..00000000 --- a/src/runtime/composables/hooks/hooks.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { HooksAdapter } from './types' - -export function defineHooksAdapter(hooks: HooksAdapter): HooksAdapter { - return hooks -} - -interface Session { - // Data of users returned by `getSession` endpoint -} - -export default defineHooksAdapter({ - signIn: { - createRequest(credentials, authState, nuxt) { - // todo - - return { - path: '', - request: { - body: credentials, - } - } - }, - - onResponse(response, authState, nuxt) { - // Possible return values: - // - false - skip any further logic (useful when onResponse handles everything); - // - {} - skip assigning tokens and session, but still possibly call getSession and redirect - // - { token: string } - assign token and continue as normal; - // - { token: string, session: object } - assign token, skip calling getSession, but do possibly call redirect; - - // todo - return { - - } - }, - }, - - getSession: { - createRequest(data, authState, nuxt) { - // todo - - return { - path: '', - request: {} - } - }, - - onResponse(response, authState, nuxt) { - return response._data as Session - } - }, - - // signOut: { - // - // } -}) - diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts index a0aa92b4..0706bd98 100644 --- a/src/runtime/composables/hooks/types.ts +++ b/src/runtime/composables/hooks/types.ts @@ -10,7 +10,7 @@ type Awaitable = T | Promise /** * The main interface defining hooks for an endpoint */ -export interface EndpointHooks { +export interface EndpointHooks { createRequest( data: CreateRequestData, authState: CommonUseAuthStateReturn, @@ -21,7 +21,13 @@ export interface EndpointHooks, authState: CommonUseAuthStateReturn, nuxt: NuxtApp, - ): Awaitable + ): Awaitable + + onError?( + errorCtx: ErrorContext, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ): Awaitable } /** Object that needs to be returned from `createRequest` in order to continue with data fetching */ @@ -80,12 +86,18 @@ export interface SignUpCreateRequestData { options?: SignUpOptions } +/** Context provided to onError hook */ +export interface ErrorContext { + error: Error + requestData: CreateRequestResult +} + // TODO Use full UseAuthStateReturn, not the CommonUseAuthStateReturn export interface HooksAdapter { // Required endpoints signIn: EndpointHooks> - getSession: EndpointHooks + getSession: EndpointHooks> // Optional endpoints signOut?: EndpointHooks | undefined> diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts index dd2d0697..a70c624d 100644 --- a/src/runtime/composables/hooks/useAuth.ts +++ b/src/runtime/composables/hooks/useAuth.ts @@ -8,12 +8,15 @@ import { getRequestURLWN } from '../common/getRequestURL' import { ERROR_PREFIX } from '../../utils/logger' import { determineCallbackUrl } from '../../utils/callbackUrl' import { useAuthState } from './useAuthState' +import { navigateTo, nextTick, useNuxtApp, useRoute, useRuntimeConfig } from '#imports' +import type { HooksAdapter, ResponseAccept, Credentials } from './types' + // @ts-expect-error - #auth not defined import type { SessionData } from '#auth' -import { navigateTo, nextTick, useNuxtApp, useRoute, useRuntimeConfig } from '#imports' +// @ts-expect-error - #build/nuxt-auth/hooks-adapter not defined +import adapter from '#build/nuxt-auth/hooks-adapter' -import userHooks, { type Credentials, type RequestOptions } from './hooks' -import type { ResponseAccept } from './types' +const userHooks = adapter as HooksAdapter export interface SignInFunc> { ( @@ -47,7 +50,7 @@ interface UseAuthReturn extends CommonUseAuthReturn>( @@ -73,7 +75,20 @@ export function useAuth(): UseAuthReturn { return } - const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } catch (e) { + if (hooks.onError) { + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + } + + // Do not proceed when error occurred + return + } const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) if (signInResponseAccept === false) { @@ -99,7 +114,13 @@ export function useAuth(): UseAuthReturn { } /** - * Helper function for handling user-returned data from `onResponse` + * Helper function for handling user-returned data from `onResponse`. + * This applies when `onResponse` returned an object. + * + * Here is how object values will be processed: + * - `null` will reset the corresponding state; + * - `undefined` or omitted - the corresponding state will remain untouched; + * - other value - corresponding state will be set to it (string for tokens, `any` for session); */ async function acceptResponse( responseAccept: ResponseAccept, @@ -141,16 +162,28 @@ export function useAuth(): UseAuthReturn { } // Fetch - const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) - res = response._data - - // Accept what was returned by the user. - // If `false` was returned - do not proceed. - // `undefined` will reset data and continue with execution. - // Object: - // If a field was set to `null`, it will be reset. - // Omitting a field or setting to `undefined` would not modify it. - // TODO: Document this behaviour + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + res = response._data + } catch (e) { + // If user hook is present, call it and return + if (hooks.onError) { + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + } + return + } + + /* + * Accept what was returned by the user. + * If response was accepted with: + * - `false` - function will stop; + * - object - response will be accepted normally, data will not be reset; + * - `undefined`, data will be reset. + */ const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) if (signInResponseAccept === false) { return @@ -191,23 +224,39 @@ export function useAuth(): UseAuthReturn { } // Fetch - let response: FetchResponse + let response: FetchResponse | undefined loading.value = true try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } catch (e) { + if (hooks.onError) { + // Prefer user hook if it exists + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult + }, authState, nuxt) + } else { + // Clear authentication data by default + console.log('clearing auth state') + data.value = null + rawToken.value = null + console.log(authState) + } } finally { loading.value = false } lastRefreshedAt.value = new Date() - // Use response - const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) - if (getSessionResponseAccept === false) { - return - } + // Use response if call succeeded + if (response !== undefined) { + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (getSessionResponseAccept === false) { + return + } - data.value = getSessionResponseAccept + await acceptResponse(getSessionResponseAccept, false) + } // TODO Do use cookies for storing access and refresh tokens, but only to provide them to authState. // How to handle the TTL though? (probably use existing Max-Age and other cookie settings; disallow HTTP-Only?) @@ -289,7 +338,21 @@ export function useAuth(): UseAuthReturn { return } - const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } catch (e) { + if (hooks.onError) { + // If user hook is present, call it and return + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + return + } else { + throw e + } + } const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) if (signUpResponseAccept === false) { @@ -317,11 +380,6 @@ export function useAuth(): UseAuthReturn { return getSession(options) } - // TODO Re-check the implementation - assume that any of these can be returned: - // - new session; - // - new access token; - // - new refresh token; - // Create request const createRequestResult = await Promise.resolve(hooks.createRequest(options, authState, nuxt)) if (createRequestResult === false) { @@ -329,7 +387,21 @@ export function useAuth(): UseAuthReturn { } // Fetch - const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } catch (e) { + if (hooks.onError) { + // If user hook is present, call it and return + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + return + } else { + throw e + } + } // Use response const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) @@ -358,3 +430,12 @@ export function useAuth(): UseAuthReturn { refresh } } + +function transformToError(e: unknown): Error { + if (e instanceof Error) { + return e + } else { + console.error('Unrecognized error thrown during getSession') + return new Error('Unknown error') + } +} diff --git a/src/runtime/composables/hooks/useAuthState.ts b/src/runtime/composables/hooks/useAuthState.ts index ddbe3e6a..0106da5b 100644 --- a/src/runtime/composables/hooks/useAuthState.ts +++ b/src/runtime/composables/hooks/useAuthState.ts @@ -3,7 +3,6 @@ import type { ComputedRef } from 'vue' import type { CommonUseAuthStateReturn } from '../../types' import { makeCommonAuthState } from '../commonAuthState' import { useTypedBackendConfig } from '../../helpers' -import { formatToken } from './utils/token' import type { CookieRef } from '#app' import { onMounted, useCookie, useRuntimeConfig, useState } from '#imports' // @ts-expect-error - #auth not defined @@ -28,26 +27,27 @@ export interface UseAuthStateReturn extends CommonUseAuthStateReturn() const instance = getCurrentInstance() // Re-construct state from cookie, also setup a cross-component sync via a useState hack, see https://github.com/nuxt/nuxt/issues/13020#issuecomment-1397282717 - const _rawTokenCookie = useCookie(config.token.cookieName, { + const _rawTokenCookie = useCookie(config.token.internalCookie.name, { default: () => null, - domain: config.token.cookieDomain, - maxAge: config.token.maxAgeInSeconds, - sameSite: config.token.sameSiteAttribute, - secure: config.token.secureCookieAttribute, - httpOnly: config.token.httpOnlyCookieAttribute + domain: config.token.internalCookie.domain, + maxAge: config.token.internalCookie.maxAge, + sameSite: config.token.internalCookie.sameSite, + secure: config.token.internalCookie.secure, + // This internal cookie needs to be accessible by the module + httpOnly: false, }) const rawToken = useState('auth:raw-token', () => _rawTokenCookie.value) watch(rawToken, () => { _rawTokenCookie.value = rawToken.value }) - const token = computed(() => formatToken(rawToken.value, config)) + const token = computed(() => rawToken.value) function setToken(newToken: string | null) { rawToken.value = newToken } @@ -55,7 +55,7 @@ export function useAuthState(): UseAuthStateReturn { setToken(null) } - // When the page is cached on a server, set the token on the client + // When the page is cached on a server, set the access token on the client if (instance) { onMounted(() => { if (_rawTokenCookie.value && !rawToken.value) { @@ -67,13 +67,14 @@ export function useAuthState(): UseAuthStateReturn { // Handle refresh token, for when refresh logic is enabled const rawRefreshToken = useState('auth:raw-refresh-token', () => null) if (config.refresh.isEnabled) { - const _rawRefreshTokenCookie = useCookie(config.refresh.token.cookieName, { + const _rawRefreshTokenCookie = useCookie(config.refresh.token.internalCookie.name, { default: () => null, - domain: config.refresh.token.cookieDomain, - maxAge: config.refresh.token.maxAgeInSeconds, - sameSite: config.refresh.token.sameSiteAttribute, - secure: config.refresh.token.secureCookieAttribute, - httpOnly: config.refresh.token.httpOnlyCookieAttribute + domain: config.token.internalCookie.domain, + maxAge: config.token.internalCookie.maxAge, + sameSite: config.token.internalCookie.sameSite, + secure: config.token.internalCookie.secure, + // This internal cookie needs to be accessible by the module + httpOnly: false, }) // Set default value if `useState` returned `null` diff --git a/src/runtime/helpers.ts b/src/runtime/helpers.ts index e5c21df7..5d7a8c99 100644 --- a/src/runtime/helpers.ts +++ b/src/runtime/helpers.ts @@ -1,6 +1,6 @@ // TODO: This should be merged into `./utils` import type { DeepRequired } from 'ts-essentials' -import type { ProviderAuthjs, ProviderLocal, SupportedAuthProviders } from './types' +import type { ProviderAuthjs, ProviderLocal, ProviderHooks, SupportedAuthProviders } from './types' import type { useRuntimeConfig } from '#imports' export const isProduction = process.env.NODE_ENV === 'production' @@ -10,9 +10,11 @@ export const isProduction = process.env.NODE_ENV === 'production' type RuntimeConfig = ReturnType export type ProviderAuthjsResolvedConfig = DeepRequired export type ProviderLocalResolvedConfig = DeepRequired +export type ProviderHooksResolvedConfig = DeepRequired export function useTypedBackendConfig(runtimeConfig: RuntimeConfig, type: 'authjs'): ProviderAuthjsResolvedConfig export function useTypedBackendConfig(runtimeConfig: RuntimeConfig, type: 'local'): ProviderLocalResolvedConfig +export function useTypedBackendConfig(runtimeConfig: RuntimeConfig, type: 'hooks'): ProviderHooksResolvedConfig /** * Get the backend configuration from the runtime config in a typed manner. * @@ -22,7 +24,7 @@ export function useTypedBackendConfig(runtimeConfig: RuntimeConfig, type: 'local export function useTypedBackendConfig( runtimeConfig: ReturnType, type: T -): ProviderAuthjsResolvedConfig | ProviderLocalResolvedConfig { +): ProviderAuthjsResolvedConfig | ProviderLocalResolvedConfig | ProviderHooksResolvedConfig { const provider = runtimeConfig.public.auth.provider if (provider.type === type) { return provider as DeepRequired diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 0c702a72..d7fce2a9 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -1,5 +1,6 @@ import type { ComputedRef, Ref } from 'vue' import type { RouterMethod } from 'h3' +import type { CookieSerializeOptions } from 'cookie-es' import type { SupportedProviders } from './composables/authjs/useAuth' /** @@ -55,7 +56,7 @@ export interface SessionDataObject { /** * Available `nuxt-auth` authentication providers. */ -export type SupportedAuthProviders = 'authjs' | 'local' +export type SupportedAuthProviders = 'authjs' | 'local' | 'hooks' /** * Configuration for the `local`-provider. @@ -364,7 +365,67 @@ export interface ProviderAuthjs { addDefaultCallbackUrl?: boolean | string } -export type AuthProviders = ProviderAuthjs | ProviderLocal +/** + * Configuration for the `hooks` provider. + */ +export interface ProviderHooks { + /** + * Uses the `hooks` provider to facilitate authentication. + * Read more here: https://auth.sidebase.io/guide/hooks/quick-start + */ + type: Extract + + /** + * The location of the adapter implementation. + * @see https://auth.sidebase.io/guide/hooks/quick-start#adapter + */ + // TODO Move Adapter in detail to a separate page + // TODO Use correct documentation URL above after that + adapter: string + + /** + * Settings for the access token that `nuxt-auth` receives from the endpoints and that can be used to authenticate subsequent requests. + */ + token?: { + /** + * Configuration for the internal cookie used by the module for saving the Access Token. + * @default { name: 'auth.token', maxAge: 60 * 30, sameSite: 'lax' } + */ + internalCookie?: CookieOptions + } + + /** + * Configuration for the refresh token logic of the `local` provider. + * If set to `undefined` or set to `{ isEnabled: false }`, refresh tokens will not be used. + */ + refresh?: { + /** + * Whether the refresh logic of the hooks provider is active + * @default false + */ + isEnabled?: boolean + + /** + * Settings for the refresh-token that `nuxt-auth` receives from the endpoints that is used for the `refresh` call. + */ + token?: { + /** + * Configuration for the internal cookie used by the module for saving the Refresh Token. + * @default { name: 'auth.refresh-token', maxAge: 60 * 60 * 24 * 7, sameSite: 'lax' } + */ + internalCookie?: CookieOptions + } + } +} + +export type AuthProviders = ProviderAuthjs | ProviderLocal | ProviderHooks + +export interface CookieOptions extends Omit { + /** + * The name of the cookie to use. + */ + name: string +} export interface RefreshHandler { /** From 860d5925a371ceb174f2ee1f3066d8b8bac70916 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 16:34:09 +0100 Subject: [PATCH 05/11] chore: run lint fix --- docs/guide/hooks/examples.md | 24 +++++++------ docs/guide/hooks/quick-start.md | 23 ++++++------- playground-hooks/config/hooks.ts | 3 +- src/runtime/composables/hooks/types.ts | 19 +++++------ src/runtime/composables/hooks/useAuth.ts | 43 +++++++++++++++--------- src/runtime/helpers.ts | 2 +- 6 files changed, 64 insertions(+), 50 deletions(-) diff --git a/docs/guide/hooks/examples.md b/docs/guide/hooks/examples.md index a56ccda3..e96bd2ff 100644 --- a/docs/guide/hooks/examples.md +++ b/docs/guide/hooks/examples.md @@ -65,25 +65,27 @@ export default defineHooks({ getSession: { createRequest: () => ({ path: '/auth/profile', request: { method: 'get' } }), - onResponse: (response) => response._data ?? null, + onResponse: response => response._data ?? null, }, }) ``` -## Fully-hijacking the flow +## Fully hijacking the flow If your hook performs a redirect itself or sets cookies, you can stop the default flow by returning `false`: ```ts -signIn: { - createRequest: (data) => ({ path: '/auth/login', request: { method: 'post', body: data.credentials } }), - async onResponse(response, authState, nuxt) { - // Handle everything yourself - authState.data.value = {} - authState.token.value = '' - // ... +defineHooksAdapter({ + signIn: { + createRequest: data => ({ path: '/auth/login', request: { method: 'post', body: data.credentials } }), + async onResponse(response, authState, nuxt) { + // Handle everything yourself + authState.data.value = {} + authState.token.value = '' + // ... - return false + return false + } } -} +}) ``` diff --git a/docs/guide/hooks/quick-start.md b/docs/guide/hooks/quick-start.md index c061d447..44ffb7be 100644 --- a/docs/guide/hooks/quick-start.md +++ b/docs/guide/hooks/quick-start.md @@ -30,9 +30,9 @@ Here's a quick minimal example of an adapter. Only `signIn` and `getSession` end ```ts export default defineHooksAdapter({ signIn: { - createRequest: (credentials) => ({ + createRequest: signInData => ({ path: '/auth/login', - request: { method: 'post', body: credentials }, + request: { method: 'post', body: signInData.credentials }, }), onResponse: (response) => { @@ -51,7 +51,7 @@ export default defineHooksAdapter({ path: '/auth/profile', request: { method: 'get' } }), - onResponse: (response) => response._data ?? null, + onResponse: response => response._data ?? null, }, }) ``` @@ -76,12 +76,12 @@ Each `EndpointHooks` has two functions: `createRequest` and `onResponse`. Prepare data for the fetch call. -Must return either an object: +Must return either an object conforming to: ```ts -{ +interface CreateRequestResult { // Path to the endpoint - path: string, + path: string // Request: body, headers, etc. request: NitroFetchOptions } @@ -103,13 +103,13 @@ The `response` argument is the [`ofetch` raw response](https://github.com/unjs/o #### `ResponseAccept` shape (what `onResponse` can return) -When `onResponse` returns an object (the `ResponseAccept`), it can contain: +When `onResponse` returns an object (the `ResponseAccept`), it should conform to: ```ts -{ - token?: string | null, // set or clear the access token in authState - refreshToken?: string | null, // set or clear the refresh token in authState (if refresh is enabled) - session?: any | null // set or clear the session object (when provided, `getSession` will NOT be called) +interface ResponseAccept { + token?: string | null // set or clear the access token in authState + refreshToken?: string | null // set or clear the refresh token in authState (if refresh is enabled) + session?: SessionDataType // set or clear the session object (when provided, `getSession` will NOT be called) } ``` @@ -163,4 +163,3 @@ export default defineNuxtConfig({ * `Access-Control-Allow-Credentials: true` * `Access-Control-Allow-Origin: ` (cannot be `*` when credentials are used) * The default hooks shipped with the module try to extract tokens using the configured token pointers (`token.signInResponseTokenPointer`) and headers. Use hooks only when you need more customization. - diff --git a/playground-hooks/config/hooks.ts b/playground-hooks/config/hooks.ts index c0c58b6e..ba0abf55 100644 --- a/playground-hooks/config/hooks.ts +++ b/playground-hooks/config/hooks.ts @@ -1,4 +1,5 @@ -import { array, object, jwt, optional, string, type z } from 'zod/mini' +import { array, jwt, object, optional, string } from 'zod/mini' +import type { z } from 'zod/mini' // TODO Export this from the `@sidebase/nuxt-auth' module import type { HooksAdapter } from '../../src/runtime/composables/hooks/types' diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts index 0706bd98..388dda4d 100644 --- a/src/runtime/composables/hooks/types.ts +++ b/src/runtime/composables/hooks/types.ts @@ -11,23 +11,23 @@ type Awaitable = T | Promise * The main interface defining hooks for an endpoint */ export interface EndpointHooks { - createRequest( + createRequest: ( data: CreateRequestData, authState: CommonUseAuthStateReturn, nuxt: NuxtApp, - ): Awaitable + ) => Awaitable - onResponse( + onResponse: ( response: FetchResponse, authState: CommonUseAuthStateReturn, nuxt: NuxtApp, - ): Awaitable + ) => Awaitable - onError?( + onError?: ( errorCtx: ErrorContext, authState: CommonUseAuthStateReturn, nuxt: NuxtApp, - ): Awaitable + ) => Awaitable } /** Object that needs to be returned from `createRequest` in order to continue with data fetching */ @@ -58,9 +58,9 @@ export interface SignInCreateRequestData { } /** -* Object that can be returned from some `onResponse` endpoints in order to update the auth state -* and impact the next steps. -*/ + * Object that can be returned from some `onResponse` endpoints in order to update the auth state + * and impact the next steps. + */ export interface ResponseAccept { /** * The value of the access token to be set. @@ -104,4 +104,3 @@ export interface HooksAdapter { signUp?: EndpointHooks | undefined> refresh?: EndpointHooks> } - diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts index a70c624d..c3a3d06d 100644 --- a/src/runtime/composables/hooks/useAuth.ts +++ b/src/runtime/composables/hooks/useAuth.ts @@ -3,13 +3,13 @@ import type { Ref } from 'vue' import type { FetchResponse } from 'ofetch' import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' import { useTypedBackendConfig } from '../../helpers' -import { _fetch, _fetchRaw } from '../../utils/fetch' +import { _fetchRaw } from '../../utils/fetch' import { getRequestURLWN } from '../common/getRequestURL' import { ERROR_PREFIX } from '../../utils/logger' import { determineCallbackUrl } from '../../utils/callbackUrl' import { useAuthState } from './useAuthState' import { navigateTo, nextTick, useNuxtApp, useRoute, useRuntimeConfig } from '#imports' -import type { HooksAdapter, ResponseAccept, Credentials } from './types' +import type { Credentials, HooksAdapter, ResponseAccept } from './types' // @ts-expect-error - #auth not defined import type { SessionData } from '#auth' @@ -78,7 +78,8 @@ export function useAuth(): UseAuthReturn { let response: FetchResponse try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) - } catch (e) { + } + catch (e) { if (hooks.onError) { await hooks.onError({ error: transformToError(e), @@ -166,7 +167,8 @@ export function useAuth(): UseAuthReturn { try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) res = response._data - } catch (e) { + } + catch (e) { // If user hook is present, call it and return if (hooks.onError) { await hooks.onError({ @@ -187,7 +189,8 @@ export function useAuth(): UseAuthReturn { const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) if (signInResponseAccept === false) { return - } else if (signInResponseAccept !== undefined) { + } + else if (signInResponseAccept !== undefined) { await acceptResponse(signInResponseAccept, false) shouldResetData = false } @@ -228,21 +231,24 @@ export function useAuth(): UseAuthReturn { loading.value = true try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) - } catch (e) { + } + catch (e) { if (hooks.onError) { // Prefer user hook if it exists await hooks.onError({ error: transformToError(e), requestData: createRequestResult }, authState, nuxt) - } else { + } + else { // Clear authentication data by default console.log('clearing auth state') data.value = null rawToken.value = null console.log(authState) } - } finally { + } + finally { loading.value = false } @@ -341,7 +347,8 @@ export function useAuth(): UseAuthReturn { let response: FetchResponse try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) - } catch (e) { + } + catch (e) { if (hooks.onError) { // If user hook is present, call it and return await hooks.onError({ @@ -349,7 +356,8 @@ export function useAuth(): UseAuthReturn { requestData: createRequestResult, }, authState, nuxt) return - } else { + } + else { throw e } } @@ -357,7 +365,8 @@ export function useAuth(): UseAuthReturn { const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) if (signUpResponseAccept === false) { return - } else if (signUpResponseAccept !== undefined) { + } + else if (signUpResponseAccept !== undefined) { // When an object was returned, accept it the same way as for `signIn` await acceptResponse(signUpResponseAccept, options?.callGetSession ?? false) return response._data @@ -390,7 +399,8 @@ export function useAuth(): UseAuthReturn { let response: FetchResponse try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) - } catch (e) { + } + catch (e) { if (hooks.onError) { // If user hook is present, call it and return await hooks.onError({ @@ -398,7 +408,8 @@ export function useAuth(): UseAuthReturn { requestData: createRequestResult, }, authState, nuxt) return - } else { + } + else { throw e } } @@ -407,7 +418,8 @@ export function useAuth(): UseAuthReturn { const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) if (getSessionResponseAccept === false) { return - } else if (getSessionResponseAccept !== undefined) { + } + else if (getSessionResponseAccept !== undefined) { // When an object was returned, accept it the same way as for `signIn` // and always call `getSession` when session was not provided return await acceptResponse(getSessionResponseAccept, true, options) @@ -434,7 +446,8 @@ export function useAuth(): UseAuthReturn { function transformToError(e: unknown): Error { if (e instanceof Error) { return e - } else { + } + else { console.error('Unrecognized error thrown during getSession') return new Error('Unknown error') } diff --git a/src/runtime/helpers.ts b/src/runtime/helpers.ts index 5d7a8c99..3223d339 100644 --- a/src/runtime/helpers.ts +++ b/src/runtime/helpers.ts @@ -1,6 +1,6 @@ // TODO: This should be merged into `./utils` import type { DeepRequired } from 'ts-essentials' -import type { ProviderAuthjs, ProviderLocal, ProviderHooks, SupportedAuthProviders } from './types' +import type { ProviderAuthjs, ProviderHooks, ProviderLocal, SupportedAuthProviders } from './types' import type { useRuntimeConfig } from '#imports' export const isProduction = process.env.NODE_ENV === 'production' From 89c9d51e80942320178b1e0c73bd03c612895fa3 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 16:40:40 +0100 Subject: [PATCH 06/11] fix: fix typecheck --- src/module.ts | 3 +++ src/runtime/types.ts | 12 ++++++++++++ src/runtime/utils/fetch.ts | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/module.ts b/src/module.ts index b29f3a33..bde31d95 100644 --- a/src/module.ts +++ b/src/module.ts @@ -102,6 +102,9 @@ const defaultsByBackend: { hooks: { type: 'hooks', adapter: '', // this satisfies Required and also gets caught at `!adapter` check + pages: { + login: '/login' + }, token: { // TODO Remove `as Required` cast and allow omitting properties in defaults internalCookie: { diff --git a/src/runtime/types.ts b/src/runtime/types.ts index d7fce2a9..07821773 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -383,6 +383,18 @@ export interface ProviderHooks { // TODO Use correct documentation URL above after that adapter: string + /** + * Pages that `nuxt-auth` needs to know the location off for redirects. + */ + pages?: { + /** + * Path of the login-page that the user should be redirected to, when they try to access a protected page without being logged in. + * + * @default '/login' + */ + login?: string + } + /** * Settings for the access token that `nuxt-auth` receives from the endpoints and that can be used to authenticate subsequent requests. */ diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index dd52ab32..e131e806 100644 --- a/src/runtime/utils/fetch.ts +++ b/src/runtime/utils/fetch.ts @@ -18,7 +18,7 @@ export async function _fetch( export async function _fetchRaw( nuxt: ReturnType, path: string, - fetchOptions?: Parameters[1], + fetchOptions: Parameters[1] = {}, proxyCookies = false, ): Promise> { // This fixes https://github.com/sidebase/nuxt-auth/issues/927 From ac6b9588f76feb24eb88b94be8602c77d9ae6f7b Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 16:44:40 +0100 Subject: [PATCH 07/11] test: add CI for hooks provider --- .github/workflows/ci.yaml | 37 +++++++++++++++++++++++++++++++++ playground-hooks/nuxt.config.ts | 4 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 13d8602f..b8840aea 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -104,6 +104,43 @@ jobs: - name: Run Playwright tests using Vitest with refresh enabled run: pnpm test:e2e + test-playground-hooks: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./playground-hooks + + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js ${{ env.NODE_VER }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VER }} + cache: "pnpm" + + - name: Install deps + run: pnpm i + + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + + # Check building + - run: pnpm build + + - 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: runs-on: ubuntu-latest defaults: diff --git a/playground-hooks/nuxt.config.ts b/playground-hooks/nuxt.config.ts index 41c7dfc0..36d33e85 100644 --- a/playground-hooks/nuxt.config.ts +++ b/playground-hooks/nuxt.config.ts @@ -9,7 +9,9 @@ export default defineNuxtConfig({ type: 'hooks', adapter: '~/config/hooks.ts', refresh: { - isEnabled: true, + // 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', }, }, sessionRefresh: { From 454a6983da04b62dc81f67df07b144414ff34816 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 17:13:15 +0100 Subject: [PATCH 08/11] fix: fix signup in playground --- playground-hooks/config/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground-hooks/config/hooks.ts b/playground-hooks/config/hooks.ts index ba0abf55..ff1c559b 100644 --- a/playground-hooks/config/hooks.ts +++ b/playground-hooks/config/hooks.ts @@ -14,7 +14,7 @@ const sessionSchema = object({ username: string(), name: string(), picture: optional(string()), - scope: array(string()), + scope: optional(array(string())), }) /** Demo user data */ type Session = z.infer From 72888956a08c56dd613e20c364cefad034fb5ca3 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 18:04:32 +0100 Subject: [PATCH 09/11] enh: export `defineHooksAdapter` from module --- playground-hooks/config/hooks.ts | 9 +-------- src/module.ts | 4 ++++ src/runtime/composables/hooks/defineHooksAdapter.ts | 5 +++++ 3 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 src/runtime/composables/hooks/defineHooksAdapter.ts diff --git a/playground-hooks/config/hooks.ts b/playground-hooks/config/hooks.ts index ff1c559b..179988a8 100644 --- a/playground-hooks/config/hooks.ts +++ b/playground-hooks/config/hooks.ts @@ -1,13 +1,6 @@ import { array, jwt, object, optional, string } from 'zod/mini' import type { z } from 'zod/mini' - -// TODO Export this from the `@sidebase/nuxt-auth' module -import type { HooksAdapter } from '../../src/runtime/composables/hooks/types' - -// TODO Export this from module -export function defineHooksAdapter(hooks: HooksAdapter): HooksAdapter { - return hooks -} +import { defineHooksAdapter } from '../../src/runtime/composables/hooks/defineHooksAdapter' /** Expected shape of the user object received from `getSession` demo endpoint */ const sessionSchema = object({ diff --git a/src/module.ts b/src/module.ts index bde31d95..baf2ec8c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -320,6 +320,10 @@ export interface ModulePublicRuntimeConfig { auth: ModuleOptionsNormalized } +// Allow importing hooks provider helpers from the module +export { defineHooksAdapter } from './runtime/composables/hooks/defineHooksAdapter' +export type { HooksAdapter } from './runtime/composables/hooks/types' + // Augment types for type inference in source code declare module '@nuxt/schema' { interface PublicRuntimeConfig { diff --git a/src/runtime/composables/hooks/defineHooksAdapter.ts b/src/runtime/composables/hooks/defineHooksAdapter.ts new file mode 100644 index 00000000..a71be57a --- /dev/null +++ b/src/runtime/composables/hooks/defineHooksAdapter.ts @@ -0,0 +1,5 @@ +import type { HooksAdapter } from './types' + +export function defineHooksAdapter(hooks: HooksAdapter): HooksAdapter { + return hooks +} From 4422a447455e09c277a90aa16c174dfdb8e426a6 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 18:22:48 +0100 Subject: [PATCH 10/11] test: fix signUp test --- playground-hooks/tests/hooks.spec.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/playground-hooks/tests/hooks.spec.ts b/playground-hooks/tests/hooks.spec.ts index d576da42..b8388adf 100644 --- a/playground-hooks/tests/hooks.spec.ts +++ b/playground-hooks/tests/hooks.spec.ts @@ -1,6 +1,6 @@ import { createPage, setup } from '@nuxt/test-utils/e2e' import { expect as playwrightExpect } from '@nuxt/test-utils/playwright' -import { describe, it } from 'vitest' +import { describe, expect, it } from 'vitest' const STATUS_AUTHENTICATED = 'authenticated' const STATUS_UNAUTHENTICATED = 'unauthenticated' @@ -78,6 +78,15 @@ describe('local Provider', async () => { await usernameInput.fill('newuser') await passwordInput.fill('hunter2') + // Test `preventLoginFlow` + let loginCalled = false + + page.on('request', request => { + if (request.url().includes('/api/auth/login')) { + loginCalled = true + } + }) + // Click button and wait for API to finish const responsePromise = page.waitForResponse(/\/api\/auth\/signup/) await submitButton.click() @@ -87,7 +96,16 @@ describe('local Provider', async () => { 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) + // Note: even though we use `preventLoginFlow` and logically + // one may assume that status should be unauthenticated, + // the demo signUp endpoint returns the signed in user, + // and the adapter hook picks it up, automatically signing the user in + // without an extra call to `signIn`. We therefore test this + // in a different way by checking that `/api/auth/login` was not called. + await playwrightExpect(status).toHaveText(STATUS_AUTHENTICATED) + + // Wait long enough for all network activity to settle + await page.waitForTimeout(500) + expect(loginCalled).toBe(false) }) }) From e2bda5784ddd325644fb8d73d0063b3cdf4b92b1 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Thu, 18 Dec 2025 18:23:24 +0100 Subject: [PATCH 11/11] chore: fix lint --- playground-hooks/tests/hooks.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground-hooks/tests/hooks.spec.ts b/playground-hooks/tests/hooks.spec.ts index b8388adf..3dbd731e 100644 --- a/playground-hooks/tests/hooks.spec.ts +++ b/playground-hooks/tests/hooks.spec.ts @@ -81,7 +81,7 @@ describe('local Provider', async () => { // Test `preventLoginFlow` let loginCalled = false - page.on('request', request => { + page.on('request', (request) => { if (request.url().includes('/api/auth/login')) { loginCalled = true }