diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 13d8602f..b8840aea 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 23c6d492..990d6fe2 100644 --- a/docs/.vitepress/routes/navbar.ts +++ b/docs/.vitepress/routes/navbar.ts @@ -17,6 +17,10 @@ export const routes: DefaultTheme.Config['nav'] = [ text: 'Local guide', link: '/guide/local/quick-start', }, + { + text: 'Hooks guide', + link: '/guide/hooks/quick-start', + }, ], }, { diff --git a/docs/.vitepress/routes/sidebar/guide.ts b/docs/.vitepress/routes/sidebar/guide.ts index d357d065..d3d37ffa 100644 --- a/docs/.vitepress/routes/sidebar/guide.ts +++ b/docs/.vitepress/routes/sidebar/guide.ts @@ -82,6 +82,20 @@ export const routes: DefaultTheme.SidebarItem[] = [ } ], }, + { + text: 'Hooks Provider', + base: '/guide/hooks', + items: [ + { + text: 'Quick Start', + link: '/quick-start', + }, + { + text: 'Examples', + link: '/examples', + } + ], + }, { text: 'Advanced', base: '/guide/advanced', diff --git a/docs/guide/hooks/examples.md b/docs/guide/hooks/examples.md new file mode 100644 index 00000000..e96bd2ff --- /dev/null +++ b/docs/guide/hooks/examples.md @@ -0,0 +1,91 @@ +# Hooks Provider examples + +## Basic `signIn` hook (body-based tokens) + +```ts +import { defineHooks } from '#imports' + +export default defineHooks({ + signIn: { + async createRequest({ credentials }) { + return { + path: '/auth/login', + request: { + method: 'post', + body: credentials, + }, + } + }, + + async onResponse(response) { + // Backend returns { access: 'xxx', refresh: 'yyy', user: {...} } + const body = response._data + return { + token: body?.access ?? undefined, + refreshToken: body?.refresh ?? undefined, + session: body?.user ?? undefined, + } + }, + }, + + getSession: { + async createRequest() { + return { + path: '/auth/profile', + request: { + method: 'get', + }, + } + }, + + async onResponse(response) { + return response._data ?? null + }, + }, +}) +``` + +## Tokens returned in headers + +```ts +export default defineHooks({ + signIn: { + createRequest: ({ credentials }) => ({ + path: '/auth/login', + request: { method: 'post', body: credentials }, + }), + + onResponse: (response) => { + const access = response.headers.get('x-access-token') + const refresh = response.headers.get('x-refresh-token') + // Don't return session — trigger a getSession call + return { token: access ?? undefined, refreshToken: refresh ?? undefined } + }, + }, + + getSession: { + createRequest: () => ({ path: '/auth/profile', request: { method: 'get' } }), + onResponse: response => response._data ?? null, + }, +}) +``` + +## Fully hijacking the flow + +If your hook performs a redirect itself or sets cookies, you can stop the default flow by returning `false`: + +```ts +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 + } + } +}) +``` diff --git a/docs/guide/hooks/quick-start.md b/docs/guide/hooks/quick-start.md new file mode 100644 index 00000000..44ffb7be --- /dev/null +++ b/docs/guide/hooks/quick-start.md @@ -0,0 +1,165 @@ +# Hooks provider + +The Hooks Provider is an advanced and highly flexible provider intended for use with external authentication backends. + +Its main difference with Local Provider is that it does not ship any default implementation and instead relies on you providing an adapter for communicating with your backend. You get complete control over how requests are built and how responses are used. + +## Configuration + +In `nuxt.config.ts`: + +```ts +export default defineNuxtConfig({ + auth: { + provider: { + type: 'hooks', + adapter: '~/app/nuxt-auth-adapter.ts', + }, + }, +}) +```` + +The path should point to a file that exports an adapter implementing `Hooks`. + +## Adapter + +### Quick example + +Here's a quick minimal example of an adapter. Only `signIn` and `getSession` endpoints are required: + +```ts +export default defineHooksAdapter({ + signIn: { + createRequest: 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, + }, +}) +``` + +### In detail + +A hooks provider expects the following adapter implementation for the auth endpoints: + +```ts +export interface HooksAdapter { + signIn: EndpointHooks + getSession: EndpointHooks + signOut?: EndpointHooks + signUp?: EndpointHooks + refresh?: EndpointHooks +} +``` + +Each `EndpointHooks` has two functions: `createRequest` and `onResponse`. + +#### `createRequest(data, authState, nuxt)` + +Prepare data for the fetch call. + +Must return either an object conforming to: + +```ts +interface CreateRequestResult { + // Path to the endpoint + path: string + // Request: body, headers, etc. + request: NitroFetchOptions +} +``` + +or `false` to stop execution (no network call will be performed). + +#### `onResponse(response, authState, nuxt)` + +Handle the response and optionally instruct the module how to update state. + +May return: +* `false` — stop further processing (module will not update auth state). +* `undefined` — proceed with default behaviour (e.g., the `signIn` flow will call `getSession` unless `signIn()` options say otherwise). +* `ResponseAccept` object — instruct the module what to set in `authState` (see below). +* Throw an `Error` to propagate a failure. + +The `response` argument is the [`ofetch` raw response](https://github.com/unjs/ofetch?tab=readme-ov-file#-access-to-raw-response) that the module uses as well. `response._data` usually contains parsed body. + +#### `ResponseAccept` shape (what `onResponse` can return) + +When `onResponse` returns an object (the `ResponseAccept`), it should conform to: + +```ts +interface ResponseAccept { + token?: string | null // set or clear the access token in authState + refreshToken?: string | null // set or clear the refresh token in authState (if refresh is enabled) + session?: SessionDataType // set or clear the session object (when provided, `getSession` will NOT be called) +} +``` + +When `token` is provided (not omitted and not `undefined`) the module will set `authState.token` (or clear it when `null`). +Same applies for `refreshToken` when refresh was enabled. + +When `session` is provided the module will use that session directly and will **not** call `getSession`. + +When the `onResponse` hook returns `undefined`, the module may call `getSession` (depending on the flow) to obtain the session. + +#### `authState` argument + +This argument gives you access to the state of the module, allowing to read or modify session data or tokens. + +#### `nuxt` argument + +This argument is provided for your convenience and to allow using Nuxt context for invoking other composables. See the [Nuxt documentation](https://nuxt.com/docs/4.x/api/composables/use-nuxt-app) for more information. + +### In short + +* `createRequest` builds and returns `{ path, request }`. The module will call `_fetchRaw(nuxt, path, request)`. + +* `onResponse` determines what the module should do next: + * `false` — stop everything (useful when the hook itself handled redirects, cookies or state changes). + * `undefined` — default behaviour (module may call `getSession`). + * `{ token?, refreshToken?, session? }` — module will set provided tokens/session in `authState`. + +## Pages + +Configure the path of the login-page that the user should be redirected to, when they try to access a protected page without being logged in. This page will also not be blocked by the global middleware. + +```ts +export default defineNuxtConfig({ + // previous configuration + auth: { + provider: { + type: 'hooks', + pages: { + login: '/login' + } + } + } +}) +``` + +## Some tips + +* When your backend uses **HTTP-only cookies** for session management, prefer returning `undefined` from `onResponse` — browsers will automatically include cookies; the module will call `getSession` to obtain the user object when needed. +* If your backend is cross-origin, remember to configure CORS and allow credentials: + + * `Access-Control-Allow-Credentials: true` + * `Access-Control-Allow-Origin: ` (cannot be `*` when credentials are used) +* The default hooks shipped with the module try to extract tokens using the configured token pointers (`token.signInResponseTokenPointer`) and headers. Use hooks only when you need more customization. diff --git a/playground-hooks/.gitignore b/playground-hooks/.gitignore new file mode 100644 index 00000000..68c5d18f --- /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 00000000..1f770a6f --- /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 00000000..037cf687 --- /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 00000000..179988a8 --- /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 00000000..36d33e85 --- /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 00000000..cefb0755 --- /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 00000000..c088043c --- /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 00000000..38b5d659 --- /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 00000000..a87a48b9 --- /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 00000000..a9786e3b --- /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 00000000..ed51ab4a --- /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 00000000..dd3dbacf --- /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 00000000..df8c3cc2 --- /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 00000000..cedbbf08 --- /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 00000000..0d7166fc --- /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 00000000..ea3be7c0 --- /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 00000000..18993ad9 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 00000000..4908991a --- /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 00000000..94c9d143 --- /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 00000000..5c971bbc --- /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 00000000..fa965ca5 --- /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 00000000..7ce7abea --- /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 00000000..f4fa852d --- /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 00000000..3dbd731e --- /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 00000000..1dc1eb73 --- /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 00000000..843ed788 --- /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 2458b241..1eb99394 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 658173c3..baf2ec8c 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: { + // TODO Remove `as Required` cast and allow omitting properties in defaults + internalCookie: { + name: 'auth.token', + maxAge: 60 * 30, // 30 minutes + sameSite: 'lax', + } as Required + }, + refresh: { + isEnabled: false, + token: { + // TODO Remove `as Required` cast and allow omitting properties in defaults + internalCookie: { + name: 'auth.refresh-token', + maxAge: 60 * 60 * 24 * 7, // 7 days + sameSite: 'lax', + } as Required + } + } + }, } const PACKAGE_NAME = 'sidebase-auth' @@ -240,6 +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 00000000..a71be57a --- /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 00000000..388dda4d --- /dev/null +++ b/src/runtime/composables/hooks/types.ts @@ -0,0 +1,106 @@ +import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack' +import type { CommonUseAuthStateReturn, GetSessionOptions, SecondarySignInOptions, SignOutOptions, SignUpOptions } from '../../types' +import type { useNuxtApp } from '#imports' +import type { FetchResponse } from 'ofetch' + +export type RequestOptions = NitroFetchOptions +type NuxtApp = ReturnType +type Awaitable = T | Promise + +/** + * The main interface defining hooks for an endpoint + */ +export interface EndpointHooks { + createRequest: ( + data: CreateRequestData, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ) => Awaitable + + onResponse: ( + response: FetchResponse, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ) => Awaitable + + onError?: ( + errorCtx: ErrorContext, + authState: CommonUseAuthStateReturn, + nuxt: NuxtApp, + ) => Awaitable +} + +/** Object that needs to be returned from `createRequest` in order to continue with data fetching */ +export interface CreateRequestResult { + /** + * Path to be provided to `$fetch`. + * It can start with `/` so that Nuxt would use function calls on server. + */ + path: string + /** + * Request to be provided to `$fetch`, can include method, body, params, etc. + * @see https://nuxt.com/docs/4.x/api/utils/dollarfetch + */ + request: RequestOptions +} + +/** Credentials accepted by `signIn` function */ +export interface Credentials extends Record { + username?: string + email?: string + password?: string +} + +/** Data provided to `signIn.createRequest` */ +export interface SignInCreateRequestData { + credentials: Credentials + options?: SecondarySignInOptions +} + +/** + * Object that can be returned from some `onResponse` endpoints in order to update the auth state + * and impact the next steps. + */ +export interface ResponseAccept { + /** + * The value of the access token to be set. + * Omit or set to `undefined` to not modify the value. + */ + token?: string | null + + /** Omit or set to `undefined` if you don't use it */ + refreshToken?: string | null + + /** + * When the session is provided, method will not call `getSession` and the session will be returned. + * Otherwise `getSession` may be called: + * - for `signIn` and `signUp` - depending on `callGetSession`; + * - for `refresh` - `getSession` will always be called in this case. + */ + session?: SessionDataType +} + +/** Data provided to `signIn.createRequest` */ +export interface SignUpCreateRequestData { + credentials: Credentials + options?: SignUpOptions +} + +/** Context provided to onError hook */ +export interface ErrorContext { + error: Error + requestData: CreateRequestResult +} + +// TODO Use full UseAuthStateReturn, not the CommonUseAuthStateReturn + +export interface HooksAdapter { + // Required endpoints + signIn: EndpointHooks> + getSession: EndpointHooks> + + // Optional endpoints + signOut?: EndpointHooks | undefined> + signUp?: EndpointHooks | undefined> + refresh?: EndpointHooks> +} diff --git a/src/runtime/composables/hooks/useAuth.ts b/src/runtime/composables/hooks/useAuth.ts new file mode 100644 index 00000000..c3a3d06d --- /dev/null +++ b/src/runtime/composables/hooks/useAuth.ts @@ -0,0 +1,454 @@ +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 + } + + 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 + console.log('clearing auth state') + data.value = null + rawToken.value = null + console.log(authState) + } + } + 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) + } + + // TODO Do use cookies for storing access and refresh tokens, but only to provide them to authState. + // How to handle the TTL though? (probably use existing Max-Age and other cookie settings; disallow HTTP-Only?) + + // TODO Add this to README FAQ: + // ## My server returns HTTP-Only cookies + // You are already set in this case - your browser will automatically send cookies with each request, + // as soon as the cookies were configured with the correct domain and path on your server. + // NuxtAuth will use `getSession` to query your server - this is how your application + // will know the authentication status. + // + // Please also note that `authState` will not have the tokens available in this case. + // + // ## My server returns tokens inside Body or Headers + // In this case you should extract the tokens inside `onResponse` hook and let NuxtAuth know about them + // by returning them from the hook, e.g. + // ```ts + // return { + // token: response._data.accessToken, + // refreshToken: response.headers.get('X-RefreshToken'), + // } + // ``` + // + // NuxtAuth will update `authState` accordingly, so you will be able to use the tokens in the later calls. + // The tokens you return will be internally stored inside cookies and + // you can configure their Max-Age (refer to the relevant documentation). + + // TODO Document accepting the response by different hooks: + // ## All hooks + // false + // Stops the function execution, does not update anything or trigger any other logic. + // Useful when hook already handled everything. + // + // Throw Error + // Stops the execution and propagates the error without handling it. + // You should be very careful when throwing from `signIn` as it is also used inside middleware. + // + // ## signIn + // Object, depending on which properties are set, will update authState and trigger other logic. + // + // ## getSession + // null - will clear the session. If `required` was used during `getSession` call, + // it will call `onUnauthenticated` or navigate the user away. + // + // Any other value - will set the session to this value. + // + // ## signOut + // + // ## signUp + // Same as `signIn`, response can be accepted using an object, + // in this case `authState` will be updated and function will return. + // + // Response can also be accepted with `undefined`, + // this will trigger `signIn` flow unless `preventLoginFlow` was given. + + // TODO Mention that `force` option does not have any effect in this provider + // TODO Deprecate the `force` option altogether in favor of a cookie-less `getSession` (and/or deprecate `local` provider) + + const { required = false, callbackUrl, onUnauthenticated, external } = getSessionOptions ?? {} + if (required && data.value === null) { + if (onUnauthenticated) { + return onUnauthenticated() + } + await navigateTo(callbackUrl ?? await getRequestURLWN(nuxt), { external }) + } + + return data.value + } + + async function signUp(credentials: Credentials, options?: SignUpOptions): Promise { + const hooks = userHooks.signUp + if (!hooks) { + console.warn(`${ERROR_PREFIX} signUp endpoint has not been configured.`) + return + } + + const createRequestResult = await Promise.resolve(hooks.createRequest({ credentials, options }, authState, nuxt)) + if (createRequestResult === false) { + return + } + + 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 00000000..0106da5b --- /dev/null +++ b/src/runtime/composables/hooks/useAuthState.ts @@ -0,0 +1,115 @@ +import { computed, getCurrentInstance, watch } from 'vue' +import type { ComputedRef } from 'vue' +import type { CommonUseAuthStateReturn } from '../../types' +import { makeCommonAuthState } from '../commonAuthState' +import { useTypedBackendConfig } from '../../helpers' +import type { CookieRef } from '#app' +import { onMounted, useCookie, useRuntimeConfig, useState } from '#imports' +// @ts-expect-error - #auth not defined +import type { SessionData } from '#auth' + +/** + * The internal response of the local-specific auth data + * + * @remarks + * The returned value `refreshToken` and `rawRefreshToken` will always be `null` if `refresh.isEnabled` is `false` + */ +export interface UseAuthStateReturn extends CommonUseAuthStateReturn { + token: ComputedRef + rawToken: CookieRef + refreshToken: ComputedRef + rawRefreshToken: CookieRef + setToken: (newToken: string | null) => void + clearToken: () => void + _internal: { + rawTokenCookie: CookieRef + } +} + +export function useAuthState(): UseAuthStateReturn { + const config = useTypedBackendConfig(useRuntimeConfig(), '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 e5c21df7..3223d339 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 0c702a72..07821773 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,79 @@ export interface ProviderAuthjs { addDefaultCallbackUrl?: boolean | string } -export type AuthProviders = ProviderAuthjs | ProviderLocal +/** + * Configuration for the `hooks` provider. + */ +export interface ProviderHooks { + /** + * Uses the `hooks` provider to facilitate authentication. + * Read more here: https://auth.sidebase.io/guide/hooks/quick-start + */ + type: Extract + + /** + * The location of the adapter implementation. + * @see https://auth.sidebase.io/guide/hooks/quick-start#adapter + */ + // TODO Move Adapter in detail to a separate page + // TODO Use correct documentation URL above after that + adapter: string + + /** + * 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 { /** diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index 6da63fe5..e131e806 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) {