From 0c3810580a4ba27d5c3ef1e7ae3e9d05294bbc10 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Fri, 17 Oct 2025 16:58:22 +0200 Subject: [PATCH 01/15] 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 000000000..a8b8d91aa --- /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 000000000..df8e708af --- /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 000000000..7258ca8a1 --- /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 000000000..ddbe3e6ac --- /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 644152ec8..1e24bb760 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/15] 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 df8e708af..47e8caf80 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 7258ca8a1..dd2d06975 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/15] 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 23c6d492c..990d6fe2d 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 d357d065b..d3d37ffa3 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 000000000..a56ccda37 --- /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 000000000..c061d4471 --- /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 a8b8d91aa..cdf6859bf 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 47e8caf80..a0aa92b4d 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/15] 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 000000000..68c5d18f0 --- /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 000000000..1f770a6f5 --- /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 000000000..037cf687e --- /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 000000000..c0c58b6ed --- /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 000000000..41c7dfc0a --- /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 000000000..cefb0755f --- /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 000000000..c088043cc --- /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 000000000..38b5d659b --- /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 000000000..a87a48b95 --- /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 000000000..a9786e3b2 --- /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 000000000..ed51ab4a9 --- /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 000000000..dd3dbacfa --- /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 000000000..df8c3cc24 --- /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 000000000..cedbbf082 --- /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 000000000..0d7166fc5 --- /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 000000000..ea3be7c05 --- /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 000000000..94c9d1436 --- /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 000000000..5c971bbc2 --- /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 000000000..fa965ca59 --- /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 000000000..7ce7abea0 --- /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 000000000..f4fa852da --- /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 000000000..d576da42a --- /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 000000000..1dc1eb73e --- /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 000000000..843ed788a --- /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 2458b2416..1eb993948 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 658173c32..b29f3a33f 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 cdf6859bf..000000000 --- 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 a0aa92b4d..0706bd98f 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 dd2d06975..a70c624db 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 ddbe3e6ac..0106da5b1 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 e5c21df7f..5d7a8c995 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 0c702a728..d7fce2a94 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/15] 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 a56ccda37..e96bd2ffb 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 c061d4471..44ffb7be8 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 c0c58b6ed..ba0abf55f 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 0706bd98f..388dda4d2 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 a70c624db..c3a3d06df 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 5d7a8c995..3223d339a 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/15] 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 b29f3a33f..bde31d954 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 d7fce2a94..07821773f 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 dd52ab329..e131e8060 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/15] 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 13d8602f2..b8840aeaa 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 41c7dfc0a..36d33e859 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/15] 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 ba0abf55f..ff1c559bb 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/15] 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 ff1c559bb..179988a8c 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 bde31d954..baf2ec8cb 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 000000000..a71be57a5 --- /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/15] 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 d576da42a..b8388adfd 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/15] 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 b8388adfd..3dbd731ee 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 } From c416c1e972f2b1c8a62d7b7b7d3318bca1c69e30 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Wed, 24 Dec 2025 17:54:52 +0100 Subject: [PATCH 12/15] chore: resolve remaining TODOs docs: improve documentation --- docs/.vitepress/routes/sidebar/guide.ts | 4 + docs/guide/hooks/adapter.md | 129 ++++++++++++++++++ docs/guide/hooks/examples.md | 90 ++++++++++-- docs/guide/hooks/quick-start.md | 89 +----------- src/module.ts | 4 +- src/runtime/composables/hooks/types.ts | 30 +++- src/runtime/composables/hooks/useAuth.ts | 68 ++------- src/runtime/composables/hooks/useAuthState.ts | 24 +--- src/runtime/types.ts | 6 +- 9 files changed, 258 insertions(+), 186 deletions(-) create mode 100644 docs/guide/hooks/adapter.md diff --git a/docs/.vitepress/routes/sidebar/guide.ts b/docs/.vitepress/routes/sidebar/guide.ts index d3d37ffa3..9855eff4c 100644 --- a/docs/.vitepress/routes/sidebar/guide.ts +++ b/docs/.vitepress/routes/sidebar/guide.ts @@ -90,6 +90,10 @@ export const routes: DefaultTheme.SidebarItem[] = [ text: 'Quick Start', link: '/quick-start', }, + { + text: 'Adapter', + link: '/adapter', + }, { text: 'Examples', link: '/examples', diff --git a/docs/guide/hooks/adapter.md b/docs/guide/hooks/adapter.md new file mode 100644 index 000000000..d4ea5c067 --- /dev/null +++ b/docs/guide/hooks/adapter.md @@ -0,0 +1,129 @@ +# Hooks adapter + +The hooks adapter gives you total control over how different authentication functions make requests, handle responses and errors. + +## In short + +* `createRequest` builds and returns `{ path, request }`. When `false` was returned, function execution fully stops. + +* The module calls `_fetchRaw(nuxt, path, request)`. + +* If an error occurs and `onError` hook was defined, the module calls it with the `Error` and request data used. In most of the functions execution will stop on error regardless if `onError` was called. + +* `onResponse` determines what the module should do next: + * `false` — the function will stop its execution. + * This is useful when the hook itself handled redirects, cookies or state changes. + * `undefined` — default behaviour, the function will continue execution, handle callbacks, `getSession` calls, etc. + * Also useful if the hook handled state/redirects/cookies. + * `{ token?, refreshToken?, session? }` — module will set provided tokens/session in `authState` and the function will continue execution. + +## 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 three functions: `createRequest` and `onResponse` (required), and `onError` (optional). + +## `createRequest(data, authState, nuxt)` + +Prepare data for the fetch call. + +Must return either an object conforming to: + +```ts +interface CreateRequestResult { + // Path to the endpoint + path: string + // Request: body, headers, etc. + request: NitroFetchOptions +} +``` + +or `false` to stop execution (no network call will be performed). + +### `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. + +## `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 should conform to: + +```ts +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) +} +``` + +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 via module configuration. + +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. + +### How different hooks handle return of `onResponse` + +* **All hooks** + * `false` - stops the function execution, does not update anything or trigger any other logic. + * `throw Error` - executes `onError` hook if it was defined and then does function-specific logic (normally stops execution). Note that `onError` hook itself may throw an error if you want to propagate it to the calling place. + * `ResponseAccept` - see block above. + +* **signIn** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. We recommend you not throwing from `onError` hook of `signIn` as this function is also used inside middleware. + +* **getSession** + * `throw Error` - does not stop the execution after calling `onError` hook if it was defined. + * We recommend you not throwing from `onError` hook of `getSession` as this function is also used inside middleware. + * When no `onError` hook was defined, the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). + * The function will then continue its normal execution, potentially navigating the user away when `required` option was used during `getSession` function call. + +* **signOut** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. + * `undefined` - the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). + +* **signUp** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. When no `onError` was defined, the error will be propagated to the caller. + * `undefined` - this will trigger `signIn` flow unless `preventLoginFlow` was given. + +* **refresh** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. When no `onError` was defined, the error will be propagated to the caller. + * `undefined` - this will trigger `getSession` call. + +## `onError(errorCtx, authState, nuxt)` + +### `errorCtx` argument + +This is an `ErrorContext` object with: +* `error: Error` — the error which was thrown during request execution. The module guarantees the type and will return `new Error('Unknown error')` when the thrown value was not an instance of `Error`. +* `requestData: CreateRequestResult` — this is the exact object which was provided by the `createRequest` hook. diff --git a/docs/guide/hooks/examples.md b/docs/guide/hooks/examples.md index e96bd2ffb..191e5f428 100644 --- a/docs/guide/hooks/examples.md +++ b/docs/guide/hooks/examples.md @@ -1,13 +1,17 @@ # Hooks Provider examples +Note that examples here are intentionally simple to demonstrate the basics of how hooks work. For a complete example using all possible hooks and [Zod](https://zod.dev/) for validating the backend responses, refer to [playground-hooks demo](https://github.com/sidebase/nuxt-auth/blob/e2bda5784ddd325644fb8d73d0063b3cdf4b92b1/playground-hooks/config/hooks.ts). + ## Basic `signIn` hook (body-based tokens) +This as an example for when your authentication backend uses POST Body to receive the credentials and tokens and to send session. + ```ts import { defineHooks } from '#imports' export default defineHooks({ signIn: { - async createRequest({ credentials }) { + createRequest({ credentials }) { return { path: '/auth/login', request: { @@ -17,9 +21,10 @@ export default defineHooks({ } }, - async onResponse(response) { + onResponse(response) { // Backend returns { access: 'xxx', refresh: 'yyy', user: {...} } const body = response._data + // Default to `undefined` to not reset the tokens and session (but you may want to reset it) return { token: body?.access ?? undefined, refreshToken: body?.refresh ?? undefined, @@ -29,17 +34,26 @@ export default defineHooks({ }, getSession: { - async createRequest() { + createRequest(_getSessionOptions, authState) { + // Avoid calling `getSession` if no access token is present + if (authState.token.value === null) { + return false + } + // Call `/auth/profile` with the method of POST + // and access token sent via Body as { token } return { path: '/auth/profile', request: { - method: 'get', + method: 'post', + body: { token: authState.token.value }, }, } }, - async onResponse(response) { - return response._data ?? null + onResponse(response) { + return { + session: response._data ?? null, + } }, }, }) @@ -47,6 +61,8 @@ export default defineHooks({ ## Tokens returned in headers +This example demonstrates how to communicate with your authentication backend using headers. + ```ts export default defineHooks({ signIn: { @@ -58,14 +74,31 @@ export default defineHooks({ 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 + // Don't return session — trigger a getSession call. + // Default to `undefined` to not reset the tokens. return { token: access ?? undefined, refreshToken: refresh ?? undefined } }, }, getSession: { - createRequest: () => ({ path: '/auth/profile', request: { method: 'get' } }), - onResponse: response => response._data ?? null, + createRequest(_getSessionOptions, authState) { + // Avoid calling `getSession` if no access token is present + if (authState.token.value === null) { + return false + } + // Call `/auth/profile` with the method of GET + // and access token added to `Authorization` header + return { + path: '/auth/profile', + request: { + method: 'get', + headers: { + Authorization: `Bearer ${authState.token.value}`, + }, + }, + } + }, + onResponse: response => ({ session: response._data ?? null }), }, }) ``` @@ -86,6 +119,43 @@ defineHooksAdapter({ return false } - } + }, + // ... +}) +``` + +## My server returns HTTP-Only cookies + +You are already almost 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 (as well as CORS). +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. + +The correct way forward for you looks like this (simplified): + +```ts +export default defineHooks({ + // signIn: ... + + getSession: { + createRequest() { + // Always call `getSession` as the module cannot see + // the tokens stored inside HTTP-Only cookies + + // Call `/auth/profile` with the method of GET + // and no tokens provided - rely on browser including them + return { + path: '/auth/profile', + request: { + method: 'get', + // Explicitly include credentials to force browser to send cookies + credentials: 'include', + }, + } + }, + onResponse: response => ({ session: response._data ?? null }), + }, + // ... }) ``` diff --git a/docs/guide/hooks/quick-start.md b/docs/guide/hooks/quick-start.md index 44ffb7be8..57dd453a9 100644 --- a/docs/guide/hooks/quick-start.md +++ b/docs/guide/hooks/quick-start.md @@ -21,13 +21,13 @@ export default defineNuxtConfig({ The path should point to a file that exports an adapter implementing `Hooks`. -## Adapter - -### Quick example +## Adapter quick example Here's a quick minimal example of an adapter. Only `signIn` and `getSession` endpoints are required: ```ts +import { defineHooksAdapter } from '@sidebase/nuxt-auth' + export default defineHooksAdapter({ signIn: { createRequest: signInData => ({ @@ -56,86 +56,8 @@ export default defineHooksAdapter({ }) ``` -### 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 conforming to: - -```ts -interface CreateRequestResult { - // 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 should conform to: - -```ts -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) -} -``` - -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`. +To see more information about setting up your adapter, please refer to [its dedicated page](./adapter.md). +See the [examples page](./examples.md) to get some inspiration. ## Pages @@ -162,4 +84,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/src/module.ts b/src/module.ts index baf2ec8cb..a13adeea4 100644 --- a/src/module.ts +++ b/src/module.ts @@ -106,7 +106,7 @@ const defaultsByBackend: { login: '/login' }, token: { - // TODO Remove `as Required` cast and allow omitting properties in defaults + // FIXME Remove `as Required` cast and allow omitting properties in defaults internalCookie: { name: 'auth.token', maxAge: 60 * 30, // 30 minutes @@ -116,7 +116,7 @@ const defaultsByBackend: { refresh: { isEnabled: false, token: { - // TODO Remove `as Required` cast and allow omitting properties in defaults + // FIXME Remove `as Required` cast and allow omitting properties in defaults internalCookie: { name: 'auth.refresh-token', maxAge: 60 * 60 * 24 * 7, // 7 days diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts index 388dda4d2..baf95f72a 100644 --- a/src/runtime/composables/hooks/types.ts +++ b/src/runtime/composables/hooks/types.ts @@ -1,31 +1,51 @@ import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack' +import type { FetchResponse } from 'ofetch' +import type { ComputedRef } from 'vue' import type { CommonUseAuthStateReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' +import type { CookieRef } from '#app' import type { useNuxtApp } from '#imports' -import type { FetchResponse } from 'ofetch' export type RequestOptions = NitroFetchOptions type NuxtApp = ReturnType type Awaitable = T | Promise +/** + * 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 + } +} + /** * The main interface defining hooks for an endpoint */ export interface EndpointHooks { createRequest: ( data: CreateRequestData, - authState: CommonUseAuthStateReturn, + authState: UseAuthStateReturn, nuxt: NuxtApp, ) => Awaitable onResponse: ( response: FetchResponse, - authState: CommonUseAuthStateReturn, + authState: UseAuthStateReturn, nuxt: NuxtApp, ) => Awaitable onError?: ( errorCtx: ErrorContext, - authState: CommonUseAuthStateReturn, + authState: UseAuthStateReturn, nuxt: NuxtApp, ) => Awaitable } @@ -92,8 +112,6 @@ export interface ErrorContext { requestData: CreateRequestResult } -// TODO Use full UseAuthStateReturn, not the CommonUseAuthStateReturn - export interface HooksAdapter { // Required endpoints signIn: EndpointHooks> diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts index c3a3d06df..fb74ca40a 100644 --- a/src/runtime/composables/hooks/useAuth.ts +++ b/src/runtime/composables/hooks/useAuth.ts @@ -218,6 +218,14 @@ export function useAuth(): UseAuthReturn { return res } + /** + * Gets the session using the configured `getSession` hook. + * + * The function normally expects that, given the valid tokens (`token`, `refreshToken`) inside `authState`, + * your backend will provide user data, so that `getSession` hook returns `session` from it + * which in turn sets authentication state (`data` and `status = authenticated`). + * This state then controls how different middleware and plugins behave. + */ async function getSession(getSessionOptions?: GetSessionOptions): Promise { // Create request const hooks = userHooks.getSession @@ -242,10 +250,9 @@ export function useAuth(): UseAuthReturn { } else { // Clear authentication data by default - console.log('clearing auth state') data.value = null rawToken.value = null - console.log(authState) + rawRefreshToken.value = null } } finally { @@ -264,63 +271,6 @@ export function useAuth(): UseAuthReturn { 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?) - - // 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) { diff --git a/src/runtime/composables/hooks/useAuthState.ts b/src/runtime/composables/hooks/useAuthState.ts index 0106da5b1..6faf02e39 100644 --- a/src/runtime/composables/hooks/useAuthState.ts +++ b/src/runtime/composables/hooks/useAuthState.ts @@ -1,32 +1,12 @@ 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 type { CookieRef } from '#app' +import type { UseAuthStateReturn } from './types' 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 { +export function useAuthState(): UseAuthStateReturn { const config = useTypedBackendConfig(useRuntimeConfig(), 'hooks') const commonAuthState = makeCommonAuthState() diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 07821773f..9c4fa431b 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -377,10 +377,8 @@ export interface ProviderHooks { /** * The location of the adapter implementation. - * @see https://auth.sidebase.io/guide/hooks/quick-start#adapter + * @see https://auth.sidebase.io/guide/hooks/adapter */ - // TODO Move Adapter in detail to a separate page - // TODO Use correct documentation URL above after that adapter: string /** @@ -680,6 +678,8 @@ export interface GetSessionOptions { onUnauthenticated?: () => void /** * Whether to refetch the session even if the token returned by useAuthState is null. + * Note: this option does not apply to `hooks` provider which relies on `getSession` hook + * to determine if the session request should happen. * * @default false */ From 136c4d70d2939eb4adaaece80e83d2f9bcea594a Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Fri, 26 Jun 2026 18:02:30 +0200 Subject: [PATCH 13/15] enh: add `extraCtx` support --- src/runtime/composables/hooks/types.ts | 66 ++++++++++++++------- src/runtime/composables/hooks/useAuth.ts | 74 ++++++++++++++---------- 2 files changed, 87 insertions(+), 53 deletions(-) diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts index baf95f72a..3a9a3e9d5 100644 --- a/src/runtime/composables/hooks/types.ts +++ b/src/runtime/composables/hooks/types.ts @@ -30,23 +30,25 @@ export interface UseAuthStateReturn extends CommonUseAuthStateRetur /** * The main interface defining hooks for an endpoint */ -export interface EndpointHooks { +export interface EndpointHooks { createRequest: ( data: CreateRequestData, authState: UseAuthStateReturn, - nuxt: NuxtApp, + nuxtApp: NuxtApp, ) => Awaitable onResponse: ( response: FetchResponse, authState: UseAuthStateReturn, - nuxt: NuxtApp, + nuxtApp: NuxtApp, + extraCtx: ExtraContextType, ) => Awaitable - onError?: ( - errorCtx: ErrorContext, + onRequestError?: ( + error: Error, authState: UseAuthStateReturn, - nuxt: NuxtApp, + nuxtApp: NuxtApp, + extraCtx: ExtraContextType, ) => Awaitable } @@ -71,12 +73,6 @@ export interface Credentials extends Record { 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. @@ -100,25 +96,51 @@ export interface ResponseAccept { session?: SessionDataType } +/** Base extra context for response and error only requires request */ +export interface BaseExtraContext { + request: CreateRequestResult +} + +/** Data provided to `signIn.createRequest` */ +export interface SignInCreateRequestData { + credentials: Credentials + options: SecondarySignInOptions | undefined +} + +/** Extra context for `signIn.onResponse` and `signIn.onRequestError` */ +export interface SignInExtraContext extends BaseExtraContext, SignInCreateRequestData {} + +/** Extra context for `getSession.onResponse` and `getSession.onRequestError` */ +export interface GetSessionExtraContext extends BaseExtraContext { + options: GetSessionOptions | undefined +} + +/** Extra context for `signOut.onResponse` and `signOut.onRequestError` */ +export interface SignOutExtraContext extends BaseExtraContext { + options: SignOutOptions | undefined +} + /** Data provided to `signIn.createRequest` */ export interface SignUpCreateRequestData { credentials: Credentials - options?: SignUpOptions + options: SignUpOptions | undefined } -/** Context provided to onError hook */ -export interface ErrorContext { - error: Error - requestData: CreateRequestResult +/** Extra context for `signUp.onResponse` and `signUp.onRequestError` */ +export interface SignUpExtraContext extends BaseExtraContext, SignUpCreateRequestData {} + +/** Extra context for `refresh.onResponse` and `refresh.onRequestError` */ +export interface RefreshExtraContext extends BaseExtraContext { + options: GetSessionOptions | undefined } export interface HooksAdapter { // Required endpoints - signIn: EndpointHooks> - getSession: EndpointHooks> + signIn: EndpointHooks, SignInExtraContext> + getSession: EndpointHooks, GetSessionExtraContext> // Optional endpoints - signOut?: EndpointHooks | undefined> - signUp?: EndpointHooks | undefined> - refresh?: EndpointHooks> + signOut?: EndpointHooks | undefined, SignOutExtraContext> + signUp?: EndpointHooks | undefined, SignUpExtraContext> + refresh?: EndpointHooks, RefreshExtraContext> } diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts index fb74ca40a..22842b5b2 100644 --- a/src/runtime/composables/hooks/useAuth.ts +++ b/src/runtime/composables/hooks/useAuth.ts @@ -9,7 +9,7 @@ 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 { Credentials, HooksAdapter, ResponseAccept } from './types' +import type { Credentials, GetSessionExtraContext, HooksAdapter, RefreshExtraContext, ResponseAccept, SignInExtraContext, SignOutExtraContext, SignUpExtraContext } from './types' // @ts-expect-error - #auth not defined import type { SessionData } from '#auth' @@ -75,23 +75,26 @@ export function useAuth(): UseAuthReturn { return } + const extraCtx: SignInExtraContext = { + request: createRequestResult, + credentials, + options, + } + 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) + if (hooks.onRequestError) { + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) } // Do not proceed when error occurred return } - const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) if (signInResponseAccept === false) { return } @@ -162,6 +165,11 @@ export function useAuth(): UseAuthReturn { return } + const extraCtx: SignOutExtraContext = { + request: createRequestResult, + options: signOutOptions, + } + // Fetch let response: FetchResponse try { @@ -170,11 +178,8 @@ export function useAuth(): UseAuthReturn { } catch (e) { // If user hook is present, call it and return - if (hooks.onError) { - await hooks.onError({ - error: transformToError(e), - requestData: createRequestResult, - }, authState, nuxt) + if (hooks.onRequestError) { + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) } return } @@ -186,7 +191,7 @@ export function useAuth(): UseAuthReturn { * - 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)) + const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) if (signInResponseAccept === false) { return } @@ -234,6 +239,11 @@ export function useAuth(): UseAuthReturn { return } + const extraCtx: GetSessionExtraContext = { + request: createRequestResult, + options: getSessionOptions, + } + // Fetch let response: FetchResponse | undefined loading.value = true @@ -241,12 +251,9 @@ export function useAuth(): UseAuthReturn { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) } catch (e) { - if (hooks.onError) { + if (hooks.onRequestError) { // Prefer user hook if it exists - await hooks.onError({ - error: transformToError(e), - requestData: createRequestResult - }, authState, nuxt) + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) } else { // Clear authentication data by default @@ -263,7 +270,7 @@ export function useAuth(): UseAuthReturn { // Use response if call succeeded if (response !== undefined) { - const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) if (getSessionResponseAccept === false) { return } @@ -294,17 +301,20 @@ export function useAuth(): UseAuthReturn { return } + const extraCtx: SignUpExtraContext = { + request: createRequestResult, + credentials, + options, + } + let response: FetchResponse try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) } catch (e) { - if (hooks.onError) { + if (hooks.onRequestError) { // If user hook is present, call it and return - await hooks.onError({ - error: transformToError(e), - requestData: createRequestResult, - }, authState, nuxt) + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) return } else { @@ -312,7 +322,7 @@ export function useAuth(): UseAuthReturn { } } - const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) if (signUpResponseAccept === false) { return } @@ -345,18 +355,20 @@ export function useAuth(): UseAuthReturn { return } + const extraCtx: RefreshExtraContext = { + request: createRequestResult, + options, + } + // Fetch let response: FetchResponse try { response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) } catch (e) { - if (hooks.onError) { + if (hooks.onRequestError) { // If user hook is present, call it and return - await hooks.onError({ - error: transformToError(e), - requestData: createRequestResult, - }, authState, nuxt) + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) return } else { @@ -365,7 +377,7 @@ export function useAuth(): UseAuthReturn { } // Use response - const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) if (getSessionResponseAccept === false) { return } From bc9c50521a90c8b1a012fa98039bc44962af6846 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Fri, 26 Jun 2026 18:02:56 +0200 Subject: [PATCH 14/15] docs: improve the documentation --- docs/guide/hooks/adapter.md | 198 ++++++++++++++++++++++---------- docs/guide/hooks/quick-start.md | 16 +-- 2 files changed, 146 insertions(+), 68 deletions(-) diff --git a/docs/guide/hooks/adapter.md b/docs/guide/hooks/adapter.md index d4ea5c067..bb9045ba7 100644 --- a/docs/guide/hooks/adapter.md +++ b/docs/guide/hooks/adapter.md @@ -4,40 +4,86 @@ The hooks adapter gives you total control over how different authentication func ## In short -* `createRequest` builds and returns `{ path, request }`. When `false` was returned, function execution fully stops. - -* The module calls `_fetchRaw(nuxt, path, request)`. - -* If an error occurs and `onError` hook was defined, the module calls it with the `Error` and request data used. In most of the functions execution will stop on error regardless if `onError` was called. - -* `onResponse` determines what the module should do next: - * `false` — the function will stop its execution. - * This is useful when the hook itself handled redirects, cookies or state changes. - * `undefined` — default behaviour, the function will continue execution, handle callbacks, `getSession` calls, etc. - * Also useful if the hook handled state/redirects/cookies. - * `{ token?, refreshToken?, session? }` — module will set provided tokens/session in `authState` and the function will continue execution. - -## In detail - -A hooks provider expects the following adapter implementation for the auth endpoints: +A hooks provider expects the following adapter implementation for auth endpoints (simplified): ```ts export interface HooksAdapter { + // Required signIn: EndpointHooks getSession: EndpointHooks + + // Optional signOut?: EndpointHooks signUp?: EndpointHooks refresh?: EndpointHooks } ``` -Each `EndpointHooks` has three functions: `createRequest` and `onResponse` (required), and `onError` (optional). +Each `EndpointHooks` has three functions: [`createRequest`](#createrequest-data-authstate-nuxtapp) and [`onResponse`](#onresponse-response-authstate-nuxtapp-extractx) (required), and [`onRequestError`](#onrequesterror-errorctx-authstate-nuxtapp) (optional). Simplified: + +```ts +interface EndpointHooks { + createRequest: ( + data: CreateRequestData, + authState: UseAuthStateReturn, + nuxtApp: NuxtApp, + ) => Awaitable + + onResponse: ( + response: FetchResponse, + authState: UseAuthStateReturn, + nuxtApp: NuxtApp, + extraCtx: ExtraContextType, + ) => Awaitable + + onRequestError?: ( + error: Error, + authState: UseAuthStateReturn, + nuxtApp: NuxtApp, + extraCtx: ExtraContextType, + ) => Awaitable +} +``` + +The execution goes as follows: + +1. `createRequest` builds and returns `{ path, request }`. When `false` was returned, function execution fully stops. + +2. The module calls `_fetchRaw(nuxtApp, path, request)`. + +3. If an error occurs and `onRequestError` hook was defined, the module calls it with the `Error` and request data used. In most of the functions execution will stop on error regardless if `onRequestError` was called. -## `createRequest(data, authState, nuxt)` +4. `onResponse` determines what the module should do next: + - `false` — the function will stop its execution. + - This is useful when the hook itself handled redirects, cookies or state changes. + - `{ token?, refreshToken?, session? }` — module will set provided tokens/session in `authState` and the function will continue execution. + - `undefined` — [special behaviour](#undefined) for some hook types. + +## `createRequest(data, authState, nuxtApp)` Prepare data for the fetch call. -Must return either an object conforming to: +### `data` + +The `data` argument depends on the hook type (e.g. `signIn`) and mirrors the input parameters for the corresponding `useAuth` function. + +### `authState` + +This argument gives you access to the state of the module, allowing to read or modify session data or tokens. It is the return value of [`useAuthState`](../application-side/session-access.md#useauthstate-composable). + +### `nuxtApp` + +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. + +### Return value + +#### `false` + +Returning `false` will stop the function execution and no network call will be performed. + +#### `CreateRequestResult` + +This instructs the module how to make a request. ```ts interface CreateRequestResult { @@ -48,29 +94,40 @@ interface CreateRequestResult { } ``` -or `false` to stop execution (no network call will be performed). - -### `authState` argument +### Errors -This argument gives you access to the state of the module, allowing to read or modify session data or tokens. +Any values thrown from `onRequestError` always get propagated to the caller. -### `nuxt` argument +> [!CAUTION] +> We strongly recommend you to **not** throw from any hooks of `signIn` and `getSession` as these functions are also used inside middleware. -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. - -## `onResponse(response, authState, nuxt)` +## `onResponse(response, authState, nuxtApp, extraCtx)` 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. +### `response` 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) +### `authState` + +Same as [`authState`](#authstate) for `createRequest`. + +### `nuxtApp` + +Same as [`nuxtApp`](#nuxtapp) for `createRequest`. + +### `extraCtx` + +The `extraCtx` argument provides the `onResponse` and `onRequestError` hooks with extra context, such as request (from `createRequest` hook) and called function inputs (e.g. `credentials` and `options` for `signIn`). + +### Return value + +#### `false` + +Returning `false` from the hook stops the function execution, does not update anything or trigger any other logic. + +#### `ResponseAccept` When `onResponse` returns an object (the `ResponseAccept`), it should conform to: @@ -90,40 +147,61 @@ 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. +#### `undefined` + +The `undefined` is only returnable by `signOut`, `signUp` and `refresh` hooks and marks special behaviour: +- `signOut` - the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). + +- `signUp` - the `signIn` flow will be triggered unless `preventLoginFlow` was given. Note: `signIn` may transitively call `getSession` to obtain the session. + +- `refresh` - the `getSession` call will be triggered. + +### Errors + +Any values thrown from `onResponse` always get propagated to the caller. + +> [!CAUTION] +> We strongly recommend you to **not** throw from any hooks of `signIn` and `getSession` as these functions are also used inside middleware. + +## `onRequestError(errorCtx, authState, nuxtApp)` + +### `error` + +This is an `Error` instance — the error which was thrown during request execution. The module guarantees the type and will return `new Error('Unknown error')` when the thrown value was not an instance of `Error`. + +### `authState` + +Same as [`authState`](#authstate) for `createRequest`. + +### `nuxtApp` + +Same as [`nuxtApp`](#nuxtapp) for `createRequest`. + +### `extraCtx` + +Same as [`extraCtx`](#extractx) for `onResponse`. + +### Errors + +Any values thrown from `onRequestError` always get propagated to the caller. -### How different hooks handle return of `onResponse` +> [!CAUTION] +> We strongly recommend you to **not** throw from any hooks of `signIn` and `getSession` as these functions are also used inside middleware. -* **All hooks** - * `false` - stops the function execution, does not update anything or trigger any other logic. - * `throw Error` - executes `onError` hook if it was defined and then does function-specific logic (normally stops execution). Note that `onError` hook itself may throw an error if you want to propagate it to the calling place. - * `ResponseAccept` - see block above. +### Special `onRequestError` behaviour -* **signIn** - * `throw Error` - stops the execution after calling `onError` hook if it was defined. We recommend you not throwing from `onError` hook of `signIn` as this function is also used inside middleware. +Some hook types have special behaviour around `onRequestError` hook: -* **getSession** - * `throw Error` - does not stop the execution after calling `onError` hook if it was defined. - * We recommend you not throwing from `onError` hook of `getSession` as this function is also used inside middleware. - * When no `onError` hook was defined, the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). - * The function will then continue its normal execution, potentially navigating the user away when `required` option was used during `getSession` function call. +#### `getSession` -* **signOut** - * `throw Error` - stops the execution after calling `onError` hook if it was defined. - * `undefined` - the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). +When no `onRequestError` hook was defined, the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). -* **signUp** - * `throw Error` - stops the execution after calling `onError` hook if it was defined. When no `onError` was defined, the error will be propagated to the caller. - * `undefined` - this will trigger `signIn` flow unless `preventLoginFlow` was given. +The function will then continue its normal execution, potentially navigating the user away when `required` option was used during `getSession` function call. -* **refresh** - * `throw Error` - stops the execution after calling `onError` hook if it was defined. When no `onError` was defined, the error will be propagated to the caller. - * `undefined` - this will trigger `getSession` call. +#### `signUp` -## `onError(errorCtx, authState, nuxt)` +When no `onRequestError` hook was defined, the error gets propagated to the caller. -### `errorCtx` argument +#### `refresh` -This is an `ErrorContext` object with: -* `error: Error` — the error which was thrown during request execution. The module guarantees the type and will return `new Error('Unknown error')` when the thrown value was not an instance of `Error`. -* `requestData: CreateRequestResult` — this is the exact object which was provided by the `createRequest` hook. +When no `onRequestError` hook was defined, the error gets propagated to the caller. diff --git a/docs/guide/hooks/quick-start.md b/docs/guide/hooks/quick-start.md index 57dd453a9..8e55ec89a 100644 --- a/docs/guide/hooks/quick-start.md +++ b/docs/guide/hooks/quick-start.md @@ -2,7 +2,7 @@ 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. +Its main difference with Local Provider is that it does not ship any default implementation and instead relies on you providing an [adapter](./adapter.md) for communicating with your backend. You get complete control over how requests are built and how responses are used. ## Configuration @@ -23,7 +23,10 @@ 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: +Below is a quick minimal example of an adapter. Only `signIn` and `getSession` endpoints are required. + +To see more information about setting up your adapter, please refer to [its dedicated page](./adapter.md). +See the [examples page](./examples.md) to get some inspiration. ```ts import { defineHooksAdapter } from '@sidebase/nuxt-auth' @@ -36,7 +39,7 @@ export default defineHooksAdapter({ }), onResponse: (response) => { - // Backend returns { access: 'xxx', refresh: 'yyy', user: {...} } + // Backend returns e.g. `{ access: 'xxx', refresh: 'yyy', user: {...} }` const body = response._data return { token: body?.access ?? undefined, @@ -56,9 +59,6 @@ export default defineHooksAdapter({ }) ``` -To see more information about setting up your adapter, please refer to [its dedicated page](./adapter.md). -See the [examples page](./examples.md) to get some inspiration. - ## 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. @@ -79,8 +79,8 @@ export default defineNuxtConfig({ ## 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: +* When your backend uses [**HTTP-only cookies**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#httponly) 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#the_http_response_headers) and allow credentials: * `Access-Control-Allow-Credentials: true` * `Access-Control-Allow-Origin: ` (cannot be `*` when credentials are used) From 62a5fc213aa964f78f98d5fdf42ca38eb2bd63e1 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Fri, 26 Jun 2026 18:36:23 +0200 Subject: [PATCH 15/15] chore: change function ordering --- src/runtime/composables/hooks/useAuth.ts | 180 +++++++++++------------ 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts index 22842b5b2..9be89858a 100644 --- a/src/runtime/composables/hooks/useAuth.ts +++ b/src/runtime/composables/hooks/useAuth.ts @@ -118,38 +118,69 @@ export function useAuth(): UseAuthReturn { } /** - * Helper function for handling user-returned data from `onResponse`. - * This applies when `onResponse` returned an object. + * Gets the session using the configured `getSession` hook. * - * 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); + * The function normally expects that, given the valid tokens (`token`, `refreshToken`) inside `authState`, + * your backend will provide user data, so that `getSession` hook returns `session` from it + * which in turn sets authentication state (`data` and `status = authenticated`). + * This state then controls how different middleware and plugins behave. */ - async function acceptResponse( - responseAccept: ResponseAccept, - callGetSession: boolean, - getSessionOptions?: GetSessionOptions, - ) { - if (responseAccept.token !== undefined) { - // Token was returned, save it - rawToken.value = responseAccept.token + 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 } - if (config.refresh.isEnabled && responseAccept.refreshToken !== undefined) { - // Refresh token was returned, save it - rawRefreshToken.value = responseAccept.refreshToken + const extraCtx: GetSessionExtraContext = { + request: createRequestResult, + options: getSessionOptions, } - if (responseAccept.session !== undefined) { - // Session was returned, use it and avoid calling getSession - data.value = responseAccept.session - lastRefreshedAt.value = new Date() + // Fetch + let response: FetchResponse | undefined + loading.value = true + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) } - else if (callGetSession) { - await nextTick() - return await getSession(getSessionOptions) + catch (e) { + if (hooks.onRequestError) { + // Prefer user hook if it exists + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) + } + else { + // Clear authentication data by default + data.value = null + rawToken.value = null + rawRefreshToken.value = null + } + } + finally { + loading.value = false } + + lastRefreshedAt.value = new Date() + + // Use response if call succeeded + if (response !== undefined) { + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) + if (getSessionResponseAccept === false) { + return + } + + await acceptResponse(getSessionResponseAccept, false) + } + + 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 signOut(signOutOptions?: SignOutOptions): Promise { @@ -223,72 +254,6 @@ export function useAuth(): UseAuthReturn { return res } - /** - * Gets the session using the configured `getSession` hook. - * - * The function normally expects that, given the valid tokens (`token`, `refreshToken`) inside `authState`, - * your backend will provide user data, so that `getSession` hook returns `session` from it - * which in turn sets authentication state (`data` and `status = authenticated`). - * This state then controls how different middleware and plugins behave. - */ - 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 - } - - const extraCtx: GetSessionExtraContext = { - request: createRequestResult, - options: getSessionOptions, - } - - // Fetch - let response: FetchResponse | undefined - loading.value = true - try { - response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) - } - catch (e) { - if (hooks.onRequestError) { - // Prefer user hook if it exists - await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) - } - else { - // Clear authentication data by default - data.value = null - rawToken.value = null - rawRefreshToken.value = null - } - } - finally { - loading.value = false - } - - lastRefreshedAt.value = new Date() - - // Use response if call succeeded - if (response !== undefined) { - const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) - if (getSessionResponseAccept === false) { - return - } - - await acceptResponse(getSessionResponseAccept, false) - } - - 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) { @@ -391,6 +356,41 @@ export function useAuth(): UseAuthReturn { return await getSession(options) } + /** + * 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, + 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) + } + } + return { status, data: readonly(data),