From 0c3810580a4ba27d5c3ef1e7ae3e9d05294bbc10 Mon Sep 17 00:00:00 2001 From: Marsel Shaikhin Date: Fri, 17 Oct 2025 16:58:22 +0200 Subject: [PATCH] feat(#964): implement hooks provider (WIP) --- src/runtime/composables/hooks/hooks.ts | 57 +++ src/runtime/composables/hooks/types.ts | 95 +++++ src/runtime/composables/hooks/useAuth.ts | 366 ++++++++++++++++++ src/runtime/composables/hooks/useAuthState.ts | 114 ++++++ src/runtime/utils/fetch.ts | 16 +- 5 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 src/runtime/composables/hooks/hooks.ts create mode 100644 src/runtime/composables/hooks/types.ts create mode 100644 src/runtime/composables/hooks/useAuth.ts create mode 100644 src/runtime/composables/hooks/useAuthState.ts diff --git a/src/runtime/composables/hooks/hooks.ts b/src/runtime/composables/hooks/hooks.ts new file mode 100644 index 00000000..a8b8d91a --- /dev/null +++ b/src/runtime/composables/hooks/hooks.ts @@ -0,0 +1,57 @@ +import type { Hooks } from './types' + +export function defineHooks(hooks: Hooks): Hooks { + return hooks +} + +interface Session { + // Data of users returned by `getSession` endpoint +} + +export default defineHooks({ + signIn: { + createRequest(credentials, authState, nuxt) { + // todo + + return { + path: '', + request: { + body: credentials, + } + } + }, + + onResponse(response, authState, nuxt) { + // Possible return values: + // - false - skip any further logic (useful when onResponse handles everything); + // - {} - skip assigning tokens and session, but still possibly call getSession and redirect + // - { token: string } - assign token and continue as normal; + // - { token: string, session: object } - assign token, skip calling getSession, but do possibly call redirect; + + // todo + return { + + } + }, + }, + + getSession: { + createRequest(data, authState, nuxt) { + // todo + + return { + path: '', + request: {} + } + }, + + onResponse(response, authState, nuxt) { + return response._data as Session + } + }, + + // signOut: { + // + // } +}) + diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts new file mode 100644 index 00000000..df8e708a --- /dev/null +++ b/src/runtime/composables/hooks/types.ts @@ -0,0 +1,95 @@ +import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack' +import type { CommonUseAuthStateReturn, GetSessionOptions, SecondarySignInOptions, SignUpOptions } from '../../types' +import type { useNuxtApp } from '#imports' +import type { FetchResponse } from 'ofetch' + +export type RequestOptions = NitroFetchOptions +type NuxtApp = ReturnType +type Awaitable = T | Promise + +/** + * The main interface defining hooks for an endpoint + */ +export interface EndpointHooks { + createRequest( + data: CreateRequestData, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ): Awaitable + + onResponse( + response: FetchResponse, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ): Awaitable +} + +/** Object that needs to be returned from `createRequest` in order to continue with data fetching */ +export interface CreateRequestResult { + /** + * Path to be provided to `$fetch`. + * It can start with `/` so that Nuxt would use function calls on server. + */ + path: string + /** + * Request to be provided to `$fetch`, can include method, body, params, etc. + * @see https://nuxt.com/docs/4.x/api/utils/dollarfetch + */ + request: RequestOptions +} + +/** Credentials accepted by `signIn` function */ +export interface Credentials extends Record { + username?: string + email?: string + password?: string +} + +/** Data provided to `signIn.createRequest` */ +export interface SignInCreateRequestData { + credentials: Credentials + options?: SecondarySignInOptions +} + +/** +* Object that can be returned from some `onResponse` endpoints in order to update the auth state +* and impact the next steps. +*/ +export interface ResponseAccept { + /** + * The value of the access token to be set. + * Omit or set to `undefined` to not modify the value. + */ + token?: string | null + + /** Omit or set to `undefined` if you don't use it */ + refreshToken?: string + + /** + * When the session is provided, method will not call `getSession` and the session will be returned. + * Otherwise `getSession` may be called: + * - for `signIn` and `signUp` - depending on `callGetSession`; + * - for `refresh` - `getSession` will always be called in this case. + */ + session?: SessionDataType +} + +/** Data provided to `signIn.createRequest` */ +export interface SignUpCreateRequestData { + credentials: Credentials + options?: SignUpOptions +} + +// TODO Use full UseAuthStateReturn, not the CommonUseAuthStateReturn + +export interface Hooks { + // Required endpoints + signIn: EndpointHooks> + getSession: EndpointHooks + + // Optional endpoints + signOut?: EndpointHooks + signUp?: EndpointHooks | undefined> + refresh?: EndpointHooks> +} + diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts new file mode 100644 index 00000000..7258ca8a --- /dev/null +++ b/src/runtime/composables/hooks/useAuth.ts @@ -0,0 +1,366 @@ +import { readonly } from 'vue' +import type { Ref } from 'vue' +import type { FetchResponse } from 'ofetch' +import type { CommonUseAuthReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' +import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers' +import { _fetch, _fetchRaw } from '../../utils/fetch' +import { getRequestURLWN } from '../common/getRequestURL' +import { ERROR_PREFIX } from '../../utils/logger' +import { determineCallbackUrl } from '../../utils/callbackUrl' +import { formatToken } from './utils/token' +import { useAuthState } from './useAuthState' +// @ts-expect-error - #auth not defined +import type { SessionData } from '#auth' +import { navigateTo, nextTick, useNuxtApp, useRoute, useRuntimeConfig } from '#imports' + +import userHooks, { type Credentials, type RequestOptions } from './hooks' +import type { ResponseAccept } from './types' + +export interface SignInFunc> { + ( + credentials: Credentials, + signInOptions?: SecondarySignInOptions, + paramsOptions?: Record, + headersOptions?: Record + ): Promise +} + +export interface SignUpFunc> { + (credentials: Credentials, signUpOptions?: SignUpOptions): Promise +} + +export interface SignOutFunc { + (options?: SignOutOptions): Promise +} + +/** + * Returns an extended version of CommonUseAuthReturn with local-provider specific data + * + * @remarks + * The returned value of `refreshToken` will always be `null` if `refresh.isEnabled` is `false` + */ +interface UseAuthReturn extends CommonUseAuthReturn { + signUp: SignUpFunc + token: Readonly> + refreshToken: Readonly> +} + +export function useAuth(): UseAuthReturn { + const nuxt = useNuxtApp() + const runtimeConfig = useRuntimeConfig() + const config = useTypedBackendConfig(runtimeConfig, 'local') + + const authState = useAuthState() + const { + data, + status, + lastRefreshedAt, + loading, + token, + refreshToken, + rawToken, + rawRefreshToken, + _internal + } = authState + + async function signIn>( + credentials: Credentials, + options?: SecondarySignInOptions, + ): Promise { + const hooks = userHooks.signIn + + const createRequestResult = await Promise.resolve(hooks.createRequest({ credentials, options }, authState, nuxt)) + if (createRequestResult === false) { + return + } + + const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + + const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signInResponseAccept === false) { + return + } + + const { redirect = true, external, callGetSession = true } = options ?? {} + + await acceptResponse(signInResponseAccept, callGetSession) + + if (redirect) { + let callbackUrl = options?.callbackUrl + if (typeof callbackUrl === 'undefined') { + const redirectQueryParam = useRoute()?.query?.redirect + callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString()) + } + + await navigateTo(callbackUrl, { external }) + return + } + + return response._data + } + + /** + * Helper function for handling user-returned data from `onResponse` + */ + async function acceptResponse( + responseAccept: ResponseAccept, + callGetSession: boolean, + getSessionOptions?: GetSessionOptions, + ) { + if (responseAccept.token !== undefined) { + // Token was returned, save it + rawToken.value = responseAccept.token + } + + if (config.refresh.isEnabled && responseAccept.refreshToken !== undefined) { + // Refresh token was returned, save it + rawRefreshToken.value = responseAccept.refreshToken + } + + if (responseAccept.session !== undefined) { + // Session was returned, use it and avoid calling getSession + data.value = responseAccept.session + lastRefreshedAt.value = new Date() + } + else if (callGetSession) { + await nextTick() + return await getSession(getSessionOptions) + } + } + + async function signOut(signOutOptions?: SignOutOptions): Promise { + // TODO Migrate to hooks + const signOutConfig = config.endpoints.signOut + + let request: RequestOptions | undefined + if (signOutConfig) { + request = { + method: signOutConfig.method, + headers: new Headers({ [config.token.headerName]: token.value } as HeadersInit), + } + + // If the refresh provider is used, include the refreshToken in the body + if (config.refresh.isEnabled && ['post', 'put', 'patch', 'delete'].includes(signOutConfig.method.toLowerCase())) { + // This uses refresh token pointer as we are passing `refreshToken` + const signoutRequestRefreshTokenPointer = config.refresh.token.refreshRequestTokenPointer + request.body = objectFromJsonPointer(signoutRequestRefreshTokenPointer, refreshToken.value) + } + } + + data.value = null + rawToken.value = null + rawRefreshToken.value = null + + let res: T | undefined + if (signOutConfig) { + const { path } = signOutConfig + + const hooks = userHooks.signOut + if (hooks) { + const canContinue = await Promise.resolve(hooks.createRequest(undefined, authState, nuxt)) + if (canContinue === false) { + return + } + + const response = await _fetchRaw(nuxt, path, request) + signInResponseData = response._data + + const signInResponseAccept = await Promise.resolve(hooks.onResponse?.(response, authState, nuxt)) + if (signInResponseAccept === false) { + return + } + } else { + res = await _fetch(nuxt, path, request) + } + } + + const { redirect = true, external } = signOutOptions ?? {} + + if (redirect) { + let callbackUrl = signOutOptions?.callbackUrl + if (typeof callbackUrl === 'undefined') { + const redirectQueryParam = useRoute()?.query?.redirect + callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString(), true) + } + await navigateTo(callbackUrl, { external }) + } + + return res + } + + async function getSession(getSessionOptions?: GetSessionOptions): Promise { + // Create request + const hooks = userHooks.getSession + const createRequestResult = await Promise.resolve(hooks.createRequest(getSessionOptions, authState, nuxt)) + if (createRequestResult === false) { + return + } + + // Fetch + let response: FetchResponse + loading.value = true + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } finally { + loading.value = false + } + + lastRefreshedAt.value = new Date() + + // Use response + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (getSessionResponseAccept === false) { + return + } + + data.value = getSessionResponseAccept + + // TODO Do use cookies for storing access and refresh tokens, but only to provide them to authState. + // How to handle the TTL though? (probably use existing Max-Age and other cookie settings; disallow HTTP-Only?) + + // TODO Add this to README FAQ: + // ## My server returns HTTP-Only cookies + // You are already set in this case - your browser will automatically send cookies with each request, + // as soon as the cookies were configured with the correct domain and path on your server. + // NuxtAuth will use `getSession` to query your server - this is how your application + // will know the authentication status. + // + // Please also note that `authState` will not have the tokens available in this case. + // + // ## My server returns tokens inside Body or Headers + // In this case you should extract the tokens inside `onResponse` hook and let NuxtAuth know about them + // by returning them from the hook, e.g. + // ```ts + // return { + // token: response._data.accessToken, + // refreshToken: response.headers.get('X-RefreshToken'), + // } + // ``` + // + // NuxtAuth will update `authState` accordingly, so you will be able to use the tokens in the later calls. + // The tokens you return will be internally stored inside cookies and + // you can configure their Max-Age (refer to the relevant documentation). + + // TODO Document accepting the response by different hooks: + // ## All hooks + // false + // Stops the function execution, does not update anything or trigger any other logic. + // Useful when hook already handled everything. + // + // Throw Error + // Stops the execution and propagates the error without handling it. + // You should be very careful when throwing from `signIn` as it is also used inside middleware. + // + // ## signIn + // Object, depending on which properties are set, will update authState and trigger other logic. + // + // ## getSession + // null - will clear the session. If `required` was used during `getSession` call, + // it will call `onUnauthenticated` or navigate the user away. + // + // Any other value - will set the session to this value. + // + // ## signOut + // + // ## signUp + // Same as `signIn`, response can be accepted using an object, + // in this case `authState` will be updated and function will return. + // + // Response can also be accepted with `undefined`, + // this will trigger `signIn` flow unless `preventLoginFlow` was given. + + // TODO Mention that `force` option does not have any effect in this provider + // TODO Deprecate the `force` option altogether in favor of a cookie-less `getSession` (and/or deprecate `local` provider) + + const { required = false, callbackUrl, onUnauthenticated, external } = getSessionOptions ?? {} + if (required && data.value === null) { + if (onUnauthenticated) { + return onUnauthenticated() + } + await navigateTo(callbackUrl ?? await getRequestURLWN(nuxt), { external }) + } + + return data.value + } + + async function signUp(credentials: Credentials, options?: SignUpOptions): Promise { + const hooks = userHooks.signUp + if (!hooks) { + console.warn(`${ERROR_PREFIX} signUp endpoint has not been configured.`) + return + } + + const createRequestResult = await Promise.resolve(hooks.createRequest({ credentials, options }, authState, nuxt)) + if (createRequestResult === false) { + return + } + + const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + + const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signUpResponseAccept === false) { + return + } else if (signUpResponseAccept !== undefined) { + // When an object was returned, accept it the same way as for `signIn` + await acceptResponse(signUpResponseAccept, options?.callGetSession ?? false) + return response._data + } + + if (options?.preventLoginFlow) { + return response._data + } + + // When response was accepted with `undefined` and `preventLoginFlow` was not `true`, + // proceed with sign-in. + return signIn(credentials, options) + } + + async function refresh(options?: GetSessionOptions) { + const hooks = userHooks.refresh + + // When no specific refresh endpoint was defined, use a regular `getSession` + if (!hooks) { + return getSession(options) + } + + // TODO Re-check the implementation - assume that any of these can be returned: + // - new session; + // - new access token; + // - new refresh token; + + // Create request + const createRequestResult = await Promise.resolve(hooks.createRequest(options, authState, nuxt)) + if (createRequestResult === false) { + return + } + + // Fetch + const response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + + // Use response + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (getSessionResponseAccept === false) { + return + } else if (getSessionResponseAccept !== undefined) { + // When an object was returned, accept it the same way as for `signIn` + // and always call `getSession` when session was not provided + return await acceptResponse(getSessionResponseAccept, true, options) + } + + await nextTick() + return await getSession(options) + } + + return { + status, + data: readonly(data), + lastRefreshedAt: readonly(lastRefreshedAt), + token: readonly(token), + refreshToken: readonly(refreshToken), + getSession, + signIn, + signOut, + signUp, + refresh + } +} diff --git a/src/runtime/composables/hooks/useAuthState.ts b/src/runtime/composables/hooks/useAuthState.ts new file mode 100644 index 00000000..ddbe3e6a --- /dev/null +++ b/src/runtime/composables/hooks/useAuthState.ts @@ -0,0 +1,114 @@ +import { computed, getCurrentInstance, watch } from 'vue' +import type { ComputedRef } from 'vue' +import type { CommonUseAuthStateReturn } from '../../types' +import { makeCommonAuthState } from '../commonAuthState' +import { useTypedBackendConfig } from '../../helpers' +import { formatToken } from './utils/token' +import type { CookieRef } from '#app' +import { onMounted, useCookie, useRuntimeConfig, useState } from '#imports' +// @ts-expect-error - #auth not defined +import type { SessionData } from '#auth' + +/** + * The internal response of the local-specific auth data + * + * @remarks + * The returned value `refreshToken` and `rawRefreshToken` will always be `null` if `refresh.isEnabled` is `false` + */ +export interface UseAuthStateReturn extends CommonUseAuthStateReturn { + token: ComputedRef + rawToken: CookieRef + refreshToken: ComputedRef + rawRefreshToken: CookieRef + setToken: (newToken: string | null) => void + clearToken: () => void + _internal: { + rawTokenCookie: CookieRef + } +} + +export function useAuthState(): UseAuthStateReturn { + const config = useTypedBackendConfig(useRuntimeConfig(), 'local') + const commonAuthState = makeCommonAuthState() + + const instance = getCurrentInstance() + + // Re-construct state from cookie, also setup a cross-component sync via a useState hack, see https://github.com/nuxt/nuxt/issues/13020#issuecomment-1397282717 + const _rawTokenCookie = useCookie(config.token.cookieName, { + default: () => null, + domain: config.token.cookieDomain, + maxAge: config.token.maxAgeInSeconds, + sameSite: config.token.sameSiteAttribute, + secure: config.token.secureCookieAttribute, + httpOnly: config.token.httpOnlyCookieAttribute + }) + const rawToken = useState('auth:raw-token', () => _rawTokenCookie.value) + watch(rawToken, () => { + _rawTokenCookie.value = rawToken.value + }) + + const token = computed(() => formatToken(rawToken.value, config)) + function setToken(newToken: string | null) { + rawToken.value = newToken + } + function clearToken() { + setToken(null) + } + + // When the page is cached on a server, set the token on the client + if (instance) { + onMounted(() => { + if (_rawTokenCookie.value && !rawToken.value) { + setToken(_rawTokenCookie.value) + } + }) + } + + // Handle refresh token, for when refresh logic is enabled + const rawRefreshToken = useState('auth:raw-refresh-token', () => null) + if (config.refresh.isEnabled) { + const _rawRefreshTokenCookie = useCookie(config.refresh.token.cookieName, { + default: () => null, + domain: config.refresh.token.cookieDomain, + maxAge: config.refresh.token.maxAgeInSeconds, + sameSite: config.refresh.token.sameSiteAttribute, + secure: config.refresh.token.secureCookieAttribute, + httpOnly: config.refresh.token.httpOnlyCookieAttribute + }) + + // Set default value if `useState` returned `null` + // https://github.com/sidebase/nuxt-auth/issues/896 + if (rawRefreshToken.value === null) { + rawRefreshToken.value = _rawRefreshTokenCookie.value + } + + watch(rawRefreshToken, () => { + _rawRefreshTokenCookie.value = rawRefreshToken.value + }) + + // When the page is cached on a server, set the refresh token on the client + if (instance) { + onMounted(() => { + if (_rawRefreshTokenCookie.value && !rawRefreshToken.value) { + rawRefreshToken.value = _rawRefreshTokenCookie.value + } + }) + } + } + + const refreshToken = computed(() => rawRefreshToken.value) + + return { + ...commonAuthState, + token, + rawToken, + refreshToken, + rawRefreshToken, + setToken, + clearToken, + _internal: { + rawTokenCookie: _rawTokenCookie + } + } +} +export default useAuthState diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index 644152ec..1e24bb76 100644 --- a/src/runtime/utils/fetch.ts +++ b/src/runtime/utils/fetch.ts @@ -4,13 +4,23 @@ import { useRequestEvent, useRuntimeConfig } from '#imports' import type { useNuxtApp } from '#imports' import { callWithNuxt } from '#app/nuxt' import type { H3Event } from 'h3' +import type { FetchResponse } from 'ofetch' export async function _fetch( nuxt: ReturnType, path: string, fetchOptions?: Parameters[1], - proxyCookies = false + proxyCookies = false, ): Promise { + return _fetchRaw(nuxt, path, fetchOptions, proxyCookies).then(res => res._data as T) +} + +export async function _fetchRaw( + nuxt: ReturnType, + path: string, + fetchOptions?: Parameters[1], + proxyCookies = false, +): Promise> { // This fixes https://github.com/sidebase/nuxt-auth/issues/927 const runtimeConfigOrPromise = callWithNuxt(nuxt, useRuntimeConfig) const runtimeConfig = 'public' in runtimeConfigOrPromise @@ -48,13 +58,13 @@ export async function _fetch( try { // Adapted from https://nuxt.com/docs/getting-started/data-fetching#pass-cookies-from-server-side-api-calls-on-ssr-response - return $fetch.raw(joinedPath, fetchOptions).then((res) => { + return $fetch.raw(joinedPath, fetchOptions).then((res) => { if (import.meta.server && proxyCookies && event) { const cookies = res.headers.getSetCookie() event.node.res.appendHeader('set-cookie', cookies) } - return res._data as T + return res }) } catch (error) {