diff --git a/docs/.vitepress/routes/navbar.ts b/docs/.vitepress/routes/navbar.ts index 23c6d492..990d6fe2 100644 --- a/docs/.vitepress/routes/navbar.ts +++ b/docs/.vitepress/routes/navbar.ts @@ -17,6 +17,10 @@ export const routes: DefaultTheme.Config['nav'] = [ text: 'Local guide', link: '/guide/local/quick-start', }, + { + text: 'Hooks guide', + link: '/guide/hooks/quick-start', + }, ], }, { diff --git a/docs/.vitepress/routes/sidebar/guide.ts b/docs/.vitepress/routes/sidebar/guide.ts index d357d065..d3d37ffa 100644 --- a/docs/.vitepress/routes/sidebar/guide.ts +++ b/docs/.vitepress/routes/sidebar/guide.ts @@ -82,6 +82,20 @@ export const routes: DefaultTheme.SidebarItem[] = [ } ], }, + { + text: 'Hooks Provider', + base: '/guide/hooks', + items: [ + { + text: 'Quick Start', + link: '/quick-start', + }, + { + text: 'Examples', + link: '/examples', + } + ], + }, { text: 'Advanced', base: '/guide/advanced', diff --git a/docs/guide/hooks/examples.md b/docs/guide/hooks/examples.md new file mode 100644 index 00000000..a56ccda3 --- /dev/null +++ b/docs/guide/hooks/examples.md @@ -0,0 +1,89 @@ +# Hooks Provider examples + +## Basic `signIn` hook (body-based tokens) + +```ts +import { defineHooks } from '#imports' + +export default defineHooks({ + signIn: { + async createRequest({ credentials }) { + return { + path: '/auth/login', + request: { + method: 'post', + body: credentials, + }, + } + }, + + async onResponse(response) { + // Backend returns { access: 'xxx', refresh: 'yyy', user: {...} } + const body = response._data + return { + token: body?.access ?? undefined, + refreshToken: body?.refresh ?? undefined, + session: body?.user ?? undefined, + } + }, + }, + + getSession: { + async createRequest() { + return { + path: '/auth/profile', + request: { + method: 'get', + }, + } + }, + + async onResponse(response) { + return response._data ?? null + }, + }, +}) +``` + +## Tokens returned in headers + +```ts +export default defineHooks({ + signIn: { + createRequest: ({ credentials }) => ({ + path: '/auth/login', + request: { method: 'post', body: credentials }, + }), + + onResponse: (response) => { + const access = response.headers.get('x-access-token') + const refresh = response.headers.get('x-refresh-token') + // Don't return session — trigger a getSession call + return { token: access ?? undefined, refreshToken: refresh ?? undefined } + }, + }, + + getSession: { + createRequest: () => ({ path: '/auth/profile', request: { method: 'get' } }), + onResponse: (response) => response._data ?? null, + }, +}) +``` + +## Fully-hijacking the flow + +If your hook performs a redirect itself or sets cookies, you can stop the default flow by returning `false`: + +```ts +signIn: { + createRequest: (data) => ({ path: '/auth/login', request: { method: 'post', body: data.credentials } }), + async onResponse(response, authState, nuxt) { + // Handle everything yourself + authState.data.value = {} + authState.token.value = '' + // ... + + return false + } +} +``` diff --git a/docs/guide/hooks/quick-start.md b/docs/guide/hooks/quick-start.md new file mode 100644 index 00000000..c061d447 --- /dev/null +++ b/docs/guide/hooks/quick-start.md @@ -0,0 +1,166 @@ +# Hooks provider + +The Hooks Provider is an advanced and highly flexible provider intended for use with external authentication backends. + +Its main difference with Local Provider is that it does not ship any default implementation and instead relies on you providing an adapter for communicating with your backend. You get complete control over how requests are built and how responses are used. + +## Configuration + +In `nuxt.config.ts`: + +```ts +export default defineNuxtConfig({ + auth: { + provider: { + type: 'hooks', + adapter: '~/app/nuxt-auth-adapter.ts', + }, + }, +}) +```` + +The path should point to a file that exports an adapter implementing `Hooks`. + +## Adapter + +### Quick example + +Here's a quick minimal example of an adapter. Only `signIn` and `getSession` endpoints are required: + +```ts +export default defineHooksAdapter({ + signIn: { + createRequest: (credentials) => ({ + path: '/auth/login', + request: { method: 'post', body: credentials }, + }), + + onResponse: (response) => { + // Backend returns { access: 'xxx', refresh: 'yyy', user: {...} } + const body = response._data + return { + token: body?.access ?? undefined, + refreshToken: body?.refresh ?? undefined, + session: body?.user ?? undefined, + } + }, + }, + + getSession: { + createRequest: () => ({ + path: '/auth/profile', + request: { method: 'get' } + }), + onResponse: (response) => response._data ?? null, + }, +}) +``` + +### In detail + +A hooks provider expects the following adapter implementation for the auth endpoints: + +```ts +export interface HooksAdapter { + signIn: EndpointHooks + getSession: EndpointHooks + signOut?: EndpointHooks + signUp?: EndpointHooks + refresh?: EndpointHooks +} +``` + +Each `EndpointHooks` has two functions: `createRequest` and `onResponse`. + +#### `createRequest(data, authState, nuxt)` + +Prepare data for the fetch call. + +Must return either an object: + +```ts +{ + // Path to the endpoint + path: string, + // Request: body, headers, etc. + request: NitroFetchOptions +} +``` + +or `false` to stop execution (no network call will be performed). + +#### `onResponse(response, authState, nuxt)` + +Handle the response and optionally instruct the module how to update state. + +May return: +* `false` — stop further processing (module will not update auth state). +* `undefined` — proceed with default behaviour (e.g., the `signIn` flow will call `getSession` unless `signIn()` options say otherwise). +* `ResponseAccept` object — instruct the module what to set in `authState` (see below). +* Throw an `Error` to propagate a failure. + +The `response` argument is the [`ofetch` raw response](https://github.com/unjs/ofetch?tab=readme-ov-file#-access-to-raw-response) that the module uses as well. `response._data` usually contains parsed body. + +#### `ResponseAccept` shape (what `onResponse` can return) + +When `onResponse` returns an object (the `ResponseAccept`), it can contain: + +```ts +{ + token?: string | null, // set or clear the access token in authState + refreshToken?: string | null, // set or clear the refresh token in authState (if refresh is enabled) + session?: any | null // set or clear the session object (when provided, `getSession` will NOT be called) +} +``` + +When `token` is provided (not omitted and not `undefined`) the module will set `authState.token` (or clear it when `null`). +Same applies for `refreshToken` when refresh was enabled. + +When `session` is provided the module will use that session directly and will **not** call `getSession`. + +When the `onResponse` hook returns `undefined`, the module may call `getSession` (depending on the flow) to obtain the session. + +#### `authState` argument + +This argument gives you access to the state of the module, allowing to read or modify session data or tokens. + +#### `nuxt` argument + +This argument is provided for your convenience and to allow using Nuxt context for invoking other composables. See the [Nuxt documentation](https://nuxt.com/docs/4.x/api/composables/use-nuxt-app) for more information. + +### In short + +* `createRequest` builds and returns `{ path, request }`. The module will call `_fetchRaw(nuxt, path, request)`. + +* `onResponse` determines what the module should do next: + * `false` — stop everything (useful when the hook itself handled redirects, cookies or state changes). + * `undefined` — default behaviour (module may call `getSession`). + * `{ token?, refreshToken?, session? }` — module will set provided tokens/session in `authState`. + +## Pages + +Configure the path of the login-page that the user should be redirected to, when they try to access a protected page without being logged in. This page will also not be blocked by the global middleware. + +```ts +export default defineNuxtConfig({ + // previous configuration + auth: { + provider: { + type: 'hooks', + pages: { + login: '/login' + } + } + } +}) +``` + +## Some tips + +* When your backend uses **HTTP-only cookies** for session management, prefer returning `undefined` from `onResponse` — browsers will automatically include cookies; the module will call `getSession` to obtain the user object when needed. +* If your backend is cross-origin, remember to configure CORS and allow credentials: + + * `Access-Control-Allow-Credentials: true` + * `Access-Control-Allow-Origin: ` (cannot be `*` when credentials are used) +* The default hooks shipped with the module try to extract tokens using the configured token pointers (`token.signInResponseTokenPointer`) and headers. Use hooks only when you need more customization. + diff --git a/src/runtime/composables/hooks/hooks.ts b/src/runtime/composables/hooks/hooks.ts new file mode 100644 index 00000000..cdf6859b --- /dev/null +++ b/src/runtime/composables/hooks/hooks.ts @@ -0,0 +1,57 @@ +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 new file mode 100644 index 00000000..a0aa92b4 --- /dev/null +++ b/src/runtime/composables/hooks/types.ts @@ -0,0 +1,95 @@ +import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack' +import type { CommonUseAuthStateReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, 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 | null + + /** + * 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 HooksAdapter { + // Required endpoints + signIn: EndpointHooks> + getSession: EndpointHooks + + // Optional endpoints + signOut?: EndpointHooks | undefined> + 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..dd2d0697 --- /dev/null +++ b/src/runtime/composables/hooks/useAuth.ts @@ -0,0 +1,360 @@ +import { readonly } from 'vue' +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 { getRequestURLWN } from '../common/getRequestURL' +import { ERROR_PREFIX } from '../../utils/logger' +import { determineCallbackUrl } from '../../utils/callbackUrl' +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 { + 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 + } + + // 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 + } + } + + if (shouldResetData) { + await acceptResponse({ + session: null, + token: null, + refreshToken: null, + }, false) + } + + 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) {