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..d4ea5c067 --- /dev/null +++ b/docs/guide/hooks/adapter.md @@ -0,0 +1,129 @@ +# Hooks adapter + +The hooks adapter gives you total control over how different authentication functions make requests, handle responses and errors. + +## In short + +* `createRequest` builds and returns `{ path, request }`. When `false` was returned, function execution fully stops. + +* The module calls `_fetchRaw(nuxt, path, request)`. + +* If an error occurs and `onError` hook was defined, the module calls it with the `Error` and request data used. In most of the functions execution will stop on error regardless if `onError` was called. + +* `onResponse` determines what the module should do next: + * `false` — the function will stop its execution. + * This is useful when the hook itself handled redirects, cookies or state changes. + * `undefined` — default behaviour, the function will continue execution, handle callbacks, `getSession` calls, etc. + * Also useful if the hook handled state/redirects/cookies. + * `{ token?, refreshToken?, session? }` — module will set provided tokens/session in `authState` and the function will continue execution. + +## In detail + +A hooks provider expects the following adapter implementation for the auth endpoints: + +```ts +export interface HooksAdapter { + signIn: EndpointHooks + getSession: EndpointHooks + signOut?: EndpointHooks + signUp?: EndpointHooks + refresh?: EndpointHooks +} +``` + +Each `EndpointHooks` has three functions: `createRequest` and `onResponse` (required), and `onError` (optional). + +## `createRequest(data, authState, nuxt)` + +Prepare data for the fetch call. + +Must return either an object conforming to: + +```ts +interface CreateRequestResult { + // Path to the endpoint + path: string + // Request: body, headers, etc. + request: NitroFetchOptions +} +``` + +or `false` to stop execution (no network call will be performed). + +### `authState` argument + +This argument gives you access to the state of the module, allowing to read or modify session data or tokens. + +### `nuxt` argument + +This argument is provided for your convenience and to allow using Nuxt context for invoking other composables. See the [Nuxt documentation](https://nuxt.com/docs/4.x/api/composables/use-nuxt-app) for more information. + +## `onResponse(response, authState, nuxt)` + +Handle the response and optionally instruct the module how to update state. + +May return: +* `false` — stop further processing (module will not update auth state). +* `undefined` — proceed with default behaviour (e.g., the `signIn` flow will call `getSession` unless `signIn()` options say otherwise). +* `ResponseAccept` object — instruct the module what to set in `authState` (see below). +* Throw an `Error` to propagate a failure. + +The `response` argument is the [`ofetch` raw response](https://github.com/unjs/ofetch?tab=readme-ov-file#-access-to-raw-response) that the module uses as well. `response._data` usually contains parsed body. + +### `ResponseAccept` shape (what `onResponse` can return) + +When `onResponse` returns an object (the `ResponseAccept`), it should conform to: + +```ts +interface ResponseAccept { + token?: string | null // set or clear the access token in authState + refreshToken?: string | null // set or clear the refresh token in authState (if refresh is enabled) + session?: SessionDataType // set or clear the session object (when provided, `getSession` will NOT be called) +} +``` + +NuxtAuth will update `authState` accordingly, so you will be able to use the tokens in the later calls. +The tokens you return will be internally stored inside cookies and you can configure their Max-Age via module configuration. + +When `token` is provided (not omitted and not `undefined`) the module will set `authState.token` (or clear it when `null`). +Same applies for `refreshToken` when refresh was enabled. + +When `session` is provided the module will use that session directly and will **not** call `getSession`. + +When the `onResponse` hook returns `undefined`, the module may call `getSession` (depending on the flow) to obtain the session. + +### How different hooks handle return of `onResponse` + +* **All hooks** + * `false` - stops the function execution, does not update anything or trigger any other logic. + * `throw Error` - executes `onError` hook if it was defined and then does function-specific logic (normally stops execution). Note that `onError` hook itself may throw an error if you want to propagate it to the calling place. + * `ResponseAccept` - see block above. + +* **signIn** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. We recommend you not throwing from `onError` hook of `signIn` as this function is also used inside middleware. + +* **getSession** + * `throw Error` - does not stop the execution after calling `onError` hook if it was defined. + * We recommend you not throwing from `onError` hook of `getSession` as this function is also used inside middleware. + * When no `onError` hook was defined, the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). + * The function will then continue its normal execution, potentially navigating the user away when `required` option was used during `getSession` function call. + +* **signOut** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. + * `undefined` - the authentication state will be cleared (`data`, `rawToken`, `rawRefreshToken` set to `null`). + +* **signUp** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. When no `onError` was defined, the error will be propagated to the caller. + * `undefined` - this will trigger `signIn` flow unless `preventLoginFlow` was given. + +* **refresh** + * `throw Error` - stops the execution after calling `onError` hook if it was defined. When no `onError` was defined, the error will be propagated to the caller. + * `undefined` - this will trigger `getSession` call. + +## `onError(errorCtx, authState, nuxt)` + +### `errorCtx` argument + +This is an `ErrorContext` object with: +* `error: Error` — the error which was thrown during request execution. The module guarantees the type and will return `new Error('Unknown error')` when the thrown value was not an instance of `Error`. +* `requestData: CreateRequestResult` — this is the exact object which was provided by the `createRequest` hook. diff --git a/docs/guide/hooks/examples.md b/docs/guide/hooks/examples.md 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..57dd453a9 --- /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 for communicating with your backend. You get complete control over how requests are built and how responses are used. + +## Configuration + +In `nuxt.config.ts`: + +```ts +export default defineNuxtConfig({ + auth: { + provider: { + type: 'hooks', + adapter: '~/app/nuxt-auth-adapter.ts', + }, + }, +}) +```` + +The path should point to a file that exports an adapter implementing `Hooks`. + +## Adapter quick example + +Here's a quick minimal example of an adapter. Only `signIn` and `getSession` endpoints are required: + +```ts +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 { 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, + }, +}) +``` + +To see more information about setting up your adapter, please refer to [its dedicated page](./adapter.md). +See the [examples page](./examples.md) to get some inspiration. + +## Pages + +Configure the path of the login-page that the user should be redirected to, when they try to access a protected page without being logged in. This page will also not be blocked by the global middleware. + +```ts +export default defineNuxtConfig({ + // previous configuration + auth: { + provider: { + type: 'hooks', + pages: { + login: '/login' + } + } + } +}) +``` + +## Some tips + +* When your backend uses **HTTP-only cookies** for session management, prefer returning `undefined` from `onResponse` — browsers will automatically include cookies; the module will call `getSession` to obtain the user object when needed. +* If your backend is cross-origin, remember to configure CORS and allow credentials: + + * `Access-Control-Allow-Credentials: true` + * `Access-Control-Allow-Origin: ` (cannot be `*` when credentials are used) 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..baf95f72a --- /dev/null +++ b/src/runtime/composables/hooks/types.ts @@ -0,0 +1,124 @@ +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, + nuxt: NuxtApp, + ) => Awaitable + + onResponse: ( + response: FetchResponse, + authState: UseAuthStateReturn, + nuxt: NuxtApp, + ) => Awaitable + + onError?: ( + errorCtx: ErrorContext, + authState: UseAuthStateReturn, + nuxt: NuxtApp, + ) => Awaitable +} + +/** Object that needs to be returned from `createRequest` in order to continue with data fetching */ +export interface CreateRequestResult { + /** + * Path to be provided to `$fetch`. + * It can start with `/` so that Nuxt would use function calls on server. + */ + path: string + /** + * Request to be provided to `$fetch`, can include method, body, params, etc. + * @see https://nuxt.com/docs/4.x/api/utils/dollarfetch + */ + request: RequestOptions +} + +/** Credentials accepted by `signIn` function */ +export interface Credentials extends Record { + username?: string + email?: string + password?: string +} + +/** Data provided to `signIn.createRequest` */ +export interface SignInCreateRequestData { + credentials: Credentials + options?: SecondarySignInOptions +} + +/** + * Object that can be returned from some `onResponse` endpoints in order to update the auth state + * and impact the next steps. + */ +export interface ResponseAccept { + /** + * The value of the access token to be set. + * Omit or set to `undefined` to not modify the value. + */ + token?: string | null + + /** Omit or set to `undefined` if you don't use it */ + refreshToken?: string | null + + /** + * When the session is provided, method will not call `getSession` and the session will be returned. + * Otherwise `getSession` may be called: + * - for `signIn` and `signUp` - depending on `callGetSession`; + * - for `refresh` - `getSession` will always be called in this case. + */ + session?: SessionDataType +} + +/** Data provided to `signIn.createRequest` */ +export interface SignUpCreateRequestData { + credentials: Credentials + options?: SignUpOptions +} + +/** Context provided to onError hook */ +export interface ErrorContext { + error: Error + requestData: CreateRequestResult +} + +export interface HooksAdapter { + // Required endpoints + signIn: EndpointHooks> + getSession: EndpointHooks> + + // Optional endpoints + signOut?: EndpointHooks | undefined> + signUp?: EndpointHooks | undefined> + refresh?: EndpointHooks> +} diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts new file mode 100644 index 000000000..fb74ca40a --- /dev/null +++ b/src/runtime/composables/hooks/useAuth.ts @@ -0,0 +1,404 @@ +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, HooksAdapter, ResponseAccept } 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 + } + + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } + catch (e) { + if (hooks.onError) { + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + } + + // Do not proceed when error occurred + return + } + + const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signInResponseAccept === false) { + return + } + + const { redirect = true, external, callGetSession = true } = options ?? {} + + await acceptResponse(signInResponseAccept, callGetSession) + + if (redirect) { + let callbackUrl = options?.callbackUrl + if (typeof callbackUrl === 'undefined') { + const redirectQueryParam = useRoute()?.query?.redirect + callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, redirectQueryParam?.toString()) + } + + await navigateTo(callbackUrl, { external }) + return + } + + return response._data + } + + /** + * Helper function for handling user-returned data from `onResponse`. + * 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) + } + } + + async function signOut(signOutOptions?: SignOutOptions): Promise { + const hooks = userHooks.signOut + + let res: T | undefined + let shouldResetData = true + + if (hooks) { + // Create request + const createRequestResult = await Promise.resolve(hooks.createRequest(signOutOptions, authState, nuxt)) + if (createRequestResult === false) { + return + } + + // Fetch + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + res = response._data + } + catch (e) { + // If user hook is present, call it and return + if (hooks.onError) { + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + } + return + } + + /* + * Accept what was returned by the user. + * If response was accepted with: + * - `false` - function will stop; + * - object - response will be accepted normally, data will not be reset; + * - `undefined`, data will be reset. + */ + const signInResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signInResponseAccept === false) { + return + } + 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 + } + + /** + * 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 + } + + // Fetch + let response: FetchResponse | undefined + loading.value = true + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } + catch (e) { + if (hooks.onError) { + // Prefer user hook if it exists + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult + }, authState, nuxt) + } + else { + // Clear authentication data by default + 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)) + if (getSessionResponseAccept === false) { + return + } + + await acceptResponse(getSessionResponseAccept, false) + } + + const { required = false, callbackUrl, onUnauthenticated, external } = getSessionOptions ?? {} + if (required && data.value === null) { + if (onUnauthenticated) { + return onUnauthenticated() + } + await navigateTo(callbackUrl ?? await getRequestURLWN(nuxt), { external }) + } + + return data.value + } + + async function signUp(credentials: Credentials, options?: SignUpOptions): Promise { + const hooks = userHooks.signUp + if (!hooks) { + 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 + } + + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } + catch (e) { + if (hooks.onError) { + // If user hook is present, call it and return + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + return + } + else { + throw e + } + } + + const signUpResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (signUpResponseAccept === false) { + 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 + } + + // Fetch + let response: FetchResponse + try { + response = await _fetchRaw(nuxt, createRequestResult.path, createRequestResult.request) + } + catch (e) { + if (hooks.onError) { + // If user hook is present, call it and return + await hooks.onError({ + error: transformToError(e), + requestData: createRequestResult, + }, authState, nuxt) + return + } + else { + throw e + } + } + + // Use response + const getSessionResponseAccept = await Promise.resolve(hooks.onResponse(response, authState, nuxt)) + if (getSessionResponseAccept === false) { + return + } + else if (getSessionResponseAccept !== undefined) { + // When an object was returned, accept it the same way as for `signIn` + // and always call `getSession` when session was not provided + return await acceptResponse(getSessionResponseAccept, true, options) + } + + await nextTick() + return await getSession(options) + } + + return { + status, + data: readonly(data), + lastRefreshedAt: readonly(lastRefreshedAt), + token: readonly(token), + refreshToken: readonly(refreshToken), + getSession, + signIn, + signOut, + signUp, + refresh + } +} + +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) {