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/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..9855eff4c 100644 --- a/docs/.vitepress/routes/sidebar/guide.ts +++ b/docs/.vitepress/routes/sidebar/guide.ts @@ -82,6 +82,24 @@ export const routes: DefaultTheme.SidebarItem[] = [ } ], }, + { + text: 'Hooks Provider', + base: '/guide/hooks', + items: [ + { + text: 'Quick Start', + link: '/quick-start', + }, + { + text: 'Adapter', + link: '/adapter', + }, + { + text: 'Examples', + link: '/examples', + } + ], + }, { text: 'Advanced', base: '/guide/advanced', diff --git a/docs/guide/hooks/adapter.md b/docs/guide/hooks/adapter.md new file mode 100644 index 000000000..bb9045ba7 --- /dev/null +++ b/docs/guide/hooks/adapter.md @@ -0,0 +1,207 @@ +# Hooks adapter + +The hooks adapter gives you total control over how different authentication functions make requests, handle responses and errors. + +## In short + +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`](#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. + +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. + +### `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 { + // Path to the endpoint + path: string + // Request: body, headers, etc. + request: NitroFetchOptions +} +``` + +### Errors + +Any values thrown from `onRequestError` 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. + +## `onResponse(response, authState, nuxtApp, extraCtx)` + +Handle the response and optionally instruct the module how to update state. + +### `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. + +### `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: + +```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`. + +#### `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. + +> [!CAUTION] +> We strongly recommend you to **not** throw from any hooks of `signIn` and `getSession` as these functions are also used inside middleware. + +### Special `onRequestError` behaviour + +Some hook types have special behaviour around `onRequestError` hook: + +#### `getSession` + +When no `onRequestError` 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. + +#### `signUp` + +When no `onRequestError` hook was defined, the error gets propagated to the caller. + +#### `refresh` + +When no `onRequestError` hook was defined, the error gets propagated to the caller. diff --git a/docs/guide/hooks/examples.md b/docs/guide/hooks/examples.md new file mode 100644 index 000000000..191e5f428 --- /dev/null +++ b/docs/guide/hooks/examples.md @@ -0,0 +1,161 @@ +# 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: { + createRequest({ credentials }) { + return { + path: '/auth/login', + request: { + method: 'post', + body: credentials, + }, + } + }, + + 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, + session: body?.user ?? undefined, + } + }, + }, + + getSession: { + 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: 'post', + body: { token: authState.token.value }, + }, + } + }, + + onResponse(response) { + return { + session: response._data ?? null, + } + }, + }, +}) +``` + +## Tokens returned in headers + +This example demonstrates how to communicate with your authentication backend using 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. + // Default to `undefined` to not reset the tokens. + return { token: access ?? undefined, refreshToken: refresh ?? undefined } + }, + }, + + getSession: { + 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 }), + }, +}) +``` + +## Fully hijacking the flow + +If your hook performs a redirect itself or sets cookies, you can stop the default flow by returning `false`: + +```ts +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 + } + }, + // ... +}) +``` + +## 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 new file mode 100644 index 000000000..8e55ec89a --- /dev/null +++ b/docs/guide/hooks/quick-start.md @@ -0,0 +1,86 @@ +# 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](./adapter.md) 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 + +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' + +export default defineHooksAdapter({ + signIn: { + createRequest: signInData => ({ + path: '/auth/login', + request: { method: 'post', body: signInData.credentials }, + }), + + onResponse: (response) => { + // Backend returns e.g. `{ 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, + }, +}) +``` + +## 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**](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) 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..179988a8c --- /dev/null +++ b/playground-hooks/config/hooks.ts @@ -0,0 +1,223 @@ +import { array, jwt, object, optional, string } from 'zod/mini' +import type { z } from 'zod/mini' +import { defineHooksAdapter } from '../../src/runtime/composables/hooks/defineHooksAdapter' + +/** Expected shape of the user object received from `getSession` demo endpoint */ +const sessionSchema = object({ + username: string(), + name: string(), + picture: optional(string()), + scope: optional(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..36d33e859 --- /dev/null +++ b/playground-hooks/nuxt.config.ts @@ -0,0 +1,37 @@ +export default defineNuxtConfig({ + compatibilityDate: '2024-04-03', + modules: ['../src/module.ts'], + build: { + transpile: ['jsonwebtoken'] + }, + auth: { + provider: { + type: 'hooks', + adapter: '~/config/hooks.ts', + refresh: { + // 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: { + // 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 000000000..18993ad91 Binary files /dev/null and b/playground-hooks/public/favicon.ico differ diff --git a/playground-hooks/server/api/auth/login.post.ts b/playground-hooks/server/api/auth/login.post.ts new file mode 100644 index 000000000..4908991a7 --- /dev/null +++ b/playground-hooks/server/api/auth/login.post.ts @@ -0,0 +1,25 @@ +import { createError, eventHandler, readBody } from 'h3' +import { createUserTokens, credentialsSchema, getUser } from '~/server/utils/session' + +/* + * DISCLAIMER! + * This is a demo implementation, please create your own handlers + */ + +export default eventHandler(async (event) => { + 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..3dbd731ee --- /dev/null +++ b/playground-hooks/tests/hooks.spec.ts @@ -0,0 +1,111 @@ +import { createPage, setup } from '@nuxt/test-utils/e2e' +import { expect as playwrightExpect } from '@nuxt/test-utils/playwright' +import { describe, expect, 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') + + // 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() + 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 + + // 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) + }) +}) 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..a13adeea4 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,34 @@ 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 + pages: { + login: '/login' + }, + token: { + // FIXME 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: { + // FIXME 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 +268,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, @@ -274,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 +} diff --git a/src/runtime/composables/hooks/types.ts b/src/runtime/composables/hooks/types.ts new file mode 100644 index 000000000..3a9a3e9d5 --- /dev/null +++ b/src/runtime/composables/hooks/types.ts @@ -0,0 +1,146 @@ +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' + +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: UseAuthStateReturn, + nuxtApp: NuxtApp, + ) => Awaitable + + onResponse: ( + response: FetchResponse, + authState: UseAuthStateReturn, + nuxtApp: NuxtApp, + extraCtx: ExtraContextType, + ) => Awaitable + + onRequestError?: ( + error: Error, + authState: UseAuthStateReturn, + nuxtApp: NuxtApp, + extraCtx: ExtraContextType, + ) => 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 +} + +/** + * 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 +} + +/** 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 | undefined +} + +/** 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, SignInExtraContext> + getSession: EndpointHooks, GetSessionExtraContext> + + // Optional endpoints + 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 new file mode 100644 index 000000000..9be89858a --- /dev/null +++ b/src/runtime/composables/hooks/useAuth.ts @@ -0,0 +1,416 @@ +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 { _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 { Credentials, GetSessionExtraContext, HooksAdapter, RefreshExtraContext, ResponseAccept, SignInExtraContext, SignOutExtraContext, SignUpExtraContext } from './types' + +// @ts-expect-error - #auth not defined +import type { SessionData } from '#auth' +// @ts-expect-error - #build/nuxt-auth/hooks-adapter not defined +import adapter from '#build/nuxt-auth/hooks-adapter' + +const userHooks = adapter as HooksAdapter + +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, 'hooks') + + const authState = useAuthState() + const { + data, + status, + lastRefreshedAt, + loading, + token, + refreshToken, + rawToken, + rawRefreshToken, + } = 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 extraCtx: SignInExtraContext = { + request: createRequestResult, + credentials, + options, + } + + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } + catch (e) { + 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, extraCtx)) + 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 + } + + /** + * 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 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 + } + + const extraCtx: SignOutExtraContext = { + request: createRequestResult, + options: signOutOptions, + } + + // Fetch + 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.onRequestError) { + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) + } + 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, extraCtx)) + 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 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 extraCtx: SignUpExtraContext = { + request: createRequestResult, + credentials, + options, + } + + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } + catch (e) { + if (hooks.onRequestError) { + // If user hook is present, call it and return + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) + return + } + else { + throw e + } + } + + const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) + 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) + } + + // Create request + const createRequestResult = await Promise.resolve(hooks.createRequest(options, authState, nuxt)) + if (createRequestResult === false) { + return + } + + const extraCtx: RefreshExtraContext = { + request: createRequestResult, + options, + } + + // Fetch + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } + catch (e) { + if (hooks.onRequestError) { + // If user hook is present, call it and return + await hooks.onRequestError(transformToError(e), authState, nuxt, extraCtx) + return + } + else { + throw e + } + } + + // Use response + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt, extraCtx)) + 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) + } + + /** + * 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), + lastRefreshedAt: readonly(lastRefreshedAt), + token: readonly(token), + refreshToken: readonly(refreshToken), + getSession, + signIn, + signOut, + signUp, + 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 new file mode 100644 index 000000000..6faf02e39 --- /dev/null +++ b/src/runtime/composables/hooks/useAuthState.ts @@ -0,0 +1,95 @@ +import { computed, getCurrentInstance, watch } from 'vue' +import { makeCommonAuthState } from '../commonAuthState' +import { useTypedBackendConfig } from '../../helpers' +import type { UseAuthStateReturn } from './types' +import { onMounted, useCookie, useRuntimeConfig, useState } from '#imports' +// @ts-expect-error - #auth not defined +import type { SessionData } from '#auth' + +export function useAuthState(): UseAuthStateReturn { + const config = useTypedBackendConfig(useRuntimeConfig(), 'hooks') + 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.internalCookie.name, { + default: () => null, + 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(() => rawToken.value) + function setToken(newToken: string | null) { + rawToken.value = newToken + } + function clearToken() { + setToken(null) + } + + // When the page is cached on a server, set the access 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.internalCookie.name, { + default: () => null, + 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` + // 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/helpers.ts b/src/runtime/helpers.ts index e5c21df7f..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, SupportedAuthProviders } from './types' +import type { ProviderAuthjs, ProviderHooks, ProviderLocal, 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..9c4fa431b 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,77 @@ 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/adapter + */ + 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. + */ + 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 { /** @@ -607,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 */ diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index 6da63fe54..e131e8060 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 @@ -53,13 +63,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) {