diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b995710..f0b72261 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -96,37 +96,6 @@ jobs: - name: Run Playwright tests using Vitest run: pnpm test:e2e - test-playground-refresh: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./playground-refresh - steps: - - uses: actions/checkout@v4 - - - 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 - run: pnpm test:e2e - test-playground-authjs: runs-on: ubuntu-latest defaults: diff --git a/playground-refresh/.gitignore b/playground-refresh/.gitignore deleted file mode 100644 index 68c5d18f..00000000 --- a/playground-refresh/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ diff --git a/playground-refresh/app.vue b/playground-refresh/app.vue deleted file mode 100644 index 2dc4767d..00000000 --- a/playground-refresh/app.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/playground-refresh/config/AuthRefreshHandler.ts b/playground-refresh/config/AuthRefreshHandler.ts deleted file mode 100644 index fba813fa..00000000 --- a/playground-refresh/config/AuthRefreshHandler.ts +++ /dev/null @@ -1,17 +0,0 @@ -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-refresh/nuxt.config.ts b/playground-refresh/nuxt.config.ts deleted file mode 100644 index f3d33404..00000000 --- a/playground-refresh/nuxt.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -export default defineNuxtConfig({ - compatibilityDate: '2024-04-03', - modules: ['../src/module.ts'], - build: { - transpile: ['jsonwebtoken'] - }, - auth: { - provider: { - type: 'refresh', - // refreshOnlyToken: true, - endpoints: { - getSession: { path: '/user' }, - refresh: { path: '/refresh', method: 'post' } - }, - pages: { - login: '/' - }, - token: { - signInResponseTokenPointer: '/token/accessToken', - maxAgeInSeconds: 60 * 5, // 5 min - sameSiteAttribute: 'lax' - }, - refreshToken: { - signInResponseRefreshTokenPointer: '/token/refreshToken', - refreshRequestTokenPointer: '/refreshToken' - } - }, - sessionRefresh: { - handler: './config/AuthRefreshHandler' - }, - globalAppMiddleware: { - isEnabled: true - } - }, - routeRules: { - '/with-caching': { - swr: 86400000, - auth: { - disableServerSideAuth: true - } - } - } -}) diff --git a/playground-refresh/package.json b/playground-refresh/package.json deleted file mode 100644 index 043868b7..00000000 --- a/playground-refresh/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "private": true, - "name": "nuxt-auth-playground-refresh", - "scripts": { - "typecheck": "tsc --noEmit", - "lint": "eslint . --max-warnings=0", - "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": "^3.23.8" - }, - "devDependencies": { - "@nuxt/test-utils": "^3.14.1", - "@playwright/test": "^1.46.0", - "@types/jsonwebtoken": "^9.0.6", - "@vue/test-utils": "^2.4.6", - "eslint": "^8.57.0", - "nuxt": "^3.12.4", - "typescript": "^5.5.4", - "vitest": "^1.6.0", - "vue-tsc": "^2.0.29" - } -} diff --git a/playground-refresh/pages/always-unprotected.vue b/playground-refresh/pages/always-unprotected.vue deleted file mode 100644 index 3d15dc73..00000000 --- a/playground-refresh/pages/always-unprotected.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/playground-refresh/pages/guest.vue b/playground-refresh/pages/guest.vue deleted file mode 100644 index c7a31974..00000000 --- a/playground-refresh/pages/guest.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/playground-refresh/pages/index.vue b/playground-refresh/pages/index.vue deleted file mode 100644 index 932c0119..00000000 --- a/playground-refresh/pages/index.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/playground-refresh/pages/protected/globally.vue b/playground-refresh/pages/protected/globally.vue deleted file mode 100644 index 47d3c469..00000000 --- a/playground-refresh/pages/protected/globally.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/playground-refresh/pages/protected/locally.vue b/playground-refresh/pages/protected/locally.vue deleted file mode 100644 index 3af012ec..00000000 --- a/playground-refresh/pages/protected/locally.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/playground-refresh/pages/signout.vue b/playground-refresh/pages/signout.vue deleted file mode 100644 index 2c95468f..00000000 --- a/playground-refresh/pages/signout.vue +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/playground-refresh/pages/with-caching.vue b/playground-refresh/pages/with-caching.vue deleted file mode 100644 index 0d7166fc..00000000 --- a/playground-refresh/pages/with-caching.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/playground-refresh/playwright.config.ts b/playground-refresh/playwright.config.ts deleted file mode 100644 index ea3be7c0..00000000 --- a/playground-refresh/playwright.config.ts +++ /dev/null @@ -1,77 +0,0 @@ -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-refresh/public/favicon.ico b/playground-refresh/public/favicon.ico deleted file mode 100644 index 18993ad9..00000000 Binary files a/playground-refresh/public/favicon.ico and /dev/null differ diff --git a/playground-refresh/server/api/auth/login.post.ts b/playground-refresh/server/api/auth/login.post.ts deleted file mode 100644 index 97f710bf..00000000 --- a/playground-refresh/server/api/auth/login.post.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createError, eventHandler, readBody } from 'h3' -import { z } from 'zod' -import { sign } from 'jsonwebtoken' - -export const SECRET = 'dummy' - -export default eventHandler(async (event) => { - const result = z - .object({ username: z.string().min(1), password: z.literal('hunter2') }) - .safeParse(await readBody(event)) - if (!result.success) { - throw createError({ - statusCode: 403, - statusMessage: 'Unauthorized, hint: try `hunter2` as password' - }) - } - - const expiresIn = 15 - - const { username } = result.data - - const user = { - username, - picture: 'https://github.com/nuxt.png', - name: 'User ' + username - } - - const accessToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, { - expiresIn - }) - const refreshToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, { - expiresIn: 60 * 60 * 24 - }) - - return { - token: { - accessToken, - refreshToken - } - } -}) diff --git a/playground-refresh/server/api/auth/logout.post.ts b/playground-refresh/server/api/auth/logout.post.ts deleted file mode 100644 index 7c0af47d..00000000 --- a/playground-refresh/server/api/auth/logout.post.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { eventHandler } from 'h3' - -export default eventHandler(() => ({ status: 'OK' })) diff --git a/playground-refresh/server/api/auth/refresh.post.ts b/playground-refresh/server/api/auth/refresh.post.ts deleted file mode 100644 index b3d49421..00000000 --- a/playground-refresh/server/api/auth/refresh.post.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createError, eventHandler, readBody } from 'h3' -import { sign, verify } from 'jsonwebtoken' - -export const SECRET = 'dummy' - -interface User { - username: string; - name: string; - picture: string; -} - -interface JwtPayload extends User { - scope: Array<'test' | 'user'>; - exp: number; -} - -export default eventHandler(async (event) => { - const body = await readBody<{ refreshToken: string }>(event) - - if (!body.refreshToken) { - throw createError({ - statusCode: 403, - statusMessage: 'Unauthorized, no refreshToken in payload' - }) - } - - const decoded = verify(body.refreshToken, SECRET) as JwtPayload | undefined - - if (!decoded) { - throw createError({ - statusCode: 403, - statusMessage: 'Unauthorized, refreshToken can`t be verified' - }) - } - - const expiresIn = 60 * 5 // 5 minutes - - const user: User = { - username: decoded.username, - picture: decoded.picture, - name: decoded.name - } - - const accessToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, { - expiresIn - }) - const refreshToken = sign({ ...user, scope: ['test', 'user'] }, SECRET, { - expiresIn: 60 * 60 * 24 - }) - - return { - token: { - accessToken, - refreshToken - } - } -}) diff --git a/playground-refresh/server/api/auth/user.get.ts b/playground-refresh/server/api/auth/user.get.ts deleted file mode 100644 index c5039a5e..00000000 --- a/playground-refresh/server/api/auth/user.get.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createError, eventHandler, getRequestHeader, H3Event } from 'h3' -import { verify } from 'jsonwebtoken' -import { SECRET } from './login.post' - -const TOKEN_TYPE = 'Bearer' - -const extractToken = (authHeaderValue: string) => { - const [, token] = authHeaderValue.split(`${TOKEN_TYPE} `) - return token -} - -const ensureAuth = (event: H3Event) => { - const authHeaderValue = getRequestHeader(event, 'authorization') - if (typeof authHeaderValue === 'undefined') { - throw createError({ - statusCode: 403, - statusMessage: - 'Need to pass valid Bearer-authorization header to access this endpoint' - }) - } - - const extractedToken = extractToken(authHeaderValue) - try { - return verify(extractedToken, SECRET) - } catch (error) { - console.error("Login failed. Here's the raw error:", error) - throw createError({ - statusCode: 403, - statusMessage: 'You must be logged in to use this endpoint' - }) - } -} - -export default eventHandler((event) => { - const user = ensureAuth(event) - return user -}) diff --git a/playground-refresh/tests/refresh.spec.ts b/playground-refresh/tests/refresh.spec.ts deleted file mode 100644 index e5a3b686..00000000 --- a/playground-refresh/tests/refresh.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, test } from 'vitest' -import { setup, createPage } from '@nuxt/test-utils/e2e' -import { expect as playwrightExpect } from '@playwright/test' - -const STATUS_AUTHENTICATED = 'authenticated' -const STATUS_UNAUTHENTICATED = 'unauthenticated' - -describe('Refresh Provider', async () => { - await setup({ - runner: 'vitest', - browser: true - }) - - test('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) - }) -}) diff --git a/playground-refresh/tsconfig.json b/playground-refresh/tsconfig.json deleted file mode 100644 index 1dc1eb73..00000000 --- a/playground-refresh/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./.nuxt/tsconfig.json", - "exclude": ["../docs"] -} diff --git a/playground-refresh/vitest.config.ts b/playground-refresh/vitest.config.ts deleted file mode 100644 index 843ed788..00000000 --- a/playground-refresh/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - include: ['tests/*.spec.ts'] - } -}) diff --git a/src/module.ts b/src/module.ts index 5aaf8388..1cd79f25 100644 --- a/src/module.ts +++ b/src/module.ts @@ -70,45 +70,21 @@ const defaultsByBackend: { session: { dataType: { id: 'string | number' }, dataResponsePointer: '/' - } - }, - - refresh: { - type: 'refresh', - pages: { - login: '/login' - }, - refreshOnlyToken: true, - endpoints: { - signIn: { path: '/login', method: 'post' }, - signOut: { path: '/logout', method: 'post' }, - signUp: { path: '/register', method: 'post' }, - getSession: { path: '/session', method: 'get' }, - refresh: { path: '/refresh', method: 'post' } }, - token: { - signInResponseTokenPointer: '/token', - type: 'Bearer', - cookieName: 'auth.token', - headerName: 'Authorization', - maxAgeInSeconds: 5 * 60, // 5 minutes - sameSiteAttribute: 'none', - secureCookieAttribute: false, - cookieDomain: '', - httpOnlyCookieAttribute: false - }, - refreshToken: { - signInResponseRefreshTokenPointer: '/refreshToken', - refreshRequestTokenPointer: '/refreshToken', - cookieName: 'auth.refresh-token', - maxAgeInSeconds: 60 * 60 * 24 * 7, // 7 days - secureCookieAttribute: false, - cookieDomain: '', - httpOnlyCookieAttribute: false - }, - session: { - dataType: { id: 'string | number' }, - dataResponsePointer: '/' + refresh: { + isEnabled: false, + endpoint: { path: '/refresh', method: 'post' }, + refreshOnlyToken: true, + token: { + signInResponseRefreshTokenPointer: '/refreshToken', + refreshRequestTokenPointer: '/refreshToken', + cookieName: 'auth.refresh-token', + maxAgeInSeconds: 60 * 60 * 24 * 7, // 7 days + sameSiteAttribute: 'lax', + secureCookieAttribute: false, + cookieDomain: '', + httpOnlyCookieAttribute: false + } } }, @@ -277,8 +253,8 @@ export default defineNuxtModule({ addServerPlugin(resolve('./runtime/server/plugins/assertOrigin')) } - // 7.2 Add a server-plugin to refresh the token on production-startup - if (selectedProvider === 'refresh') { + // 9. Add a plugin to refresh the token on production-startup + if (options.provider.type === 'local' && options.provider.refresh.isEnabled) { addPlugin(resolve('./runtime/plugins/refresh-token.server')) } diff --git a/src/runtime/composables/local/useAuth.ts b/src/runtime/composables/local/useAuth.ts index 63cea938..bef5f287 100644 --- a/src/runtime/composables/local/useAuth.ts +++ b/src/runtime/composables/local/useAuth.ts @@ -1,12 +1,12 @@ import { readonly, type Ref } from 'vue' import { callWithNuxt } from '#app/nuxt' -import type { CommonUseAuthReturn, SignOutFunc, SignInFunc, GetSessionFunc, SecondarySignInOptions, SignUpOptions } from '../../types' -import { jsonPointerGet, useTypedBackendConfig } from '../../helpers' +import type { CommonUseAuthReturn, SignOutFunc, SignInFunc, GetSessionFunc, SecondarySignInOptions, SignUpOptions, GetSessionOptions } from '../../types' +import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers' import { _fetch } from '../../utils/fetch' import { getRequestURLWN } from '../../utils/callWithNuxt' import { determineCallbackUrl } from '../../utils/url' import { formatToken } from '../../utils/local' -import { useAuthState } from './useAuthState' +import { useAuthState, type UseAuthStateReturn } from './useAuthState' // @ts-expect-error - #auth not defined import type { SessionData } from '#auth' import { useNuxtApp, useRuntimeConfig, nextTick, navigateTo } from '#imports' @@ -25,24 +25,43 @@ const signIn: SignInFunc = async (credentials, signInOptions, params: signInParams ?? {} }) + const { rawToken, rawRefreshToken } = useAuthState() + + // Extract the access token const extractedToken = jsonPointerGet(response, config.token.signInResponseTokenPointer) if (typeof extractedToken !== 'string') { - console.error(`Auth: string token expected, received instead: ${JSON.stringify(extractedToken)}. Tried to find token at ${config.token.signInResponseTokenPointer} in ${JSON.stringify(response)}`) + console.error( + `Auth: string token expected, received instead: ${JSON.stringify(extractedToken)}. ` + + `Tried to find token at ${config.token.signInResponseTokenPointer} in ${JSON.stringify(response)}` + ) return } - - const { rawToken } = useAuthState() rawToken.value = extractedToken + // Extract the refresh token if enabled + if (config.refresh.isEnabled) { + const refreshTokenPointer = config.refresh.token.signInResponseRefreshTokenPointer + + const extractedRefreshToken = jsonPointerGet(response, refreshTokenPointer) + if (typeof extractedRefreshToken !== 'string') { + console.error( + `Auth: string token expected, received instead: ${JSON.stringify(extractedRefreshToken)}. ` + + `Tried to find refresh token at ${refreshTokenPointer} in ${JSON.stringify(response)}` + ) + return + } + rawRefreshToken.value = extractedRefreshToken + } + await nextTick(getSession) - const { redirect = true } = signInOptions ?? {} + const { redirect = true, external } = signInOptions ?? {} let { callbackUrl } = signInOptions ?? {} if (typeof callbackUrl === 'undefined') { callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, () => getRequestURLWN(nuxt)) } if (redirect) { - return navigateTo(callbackUrl) + return navigateTo(callbackUrl, { external }) } } @@ -50,18 +69,30 @@ const signOut: SignOutFunc = async (signOutOptions) => { const nuxt = useNuxtApp() const runtimeConfig = await callWithNuxt(nuxt, useRuntimeConfig) const config = useTypedBackendConfig(runtimeConfig, 'local') - const { data, rawToken, token } = await callWithNuxt(nuxt, useAuthState) + const { data, token, rawToken, refreshToken, rawRefreshToken }: UseAuthStateReturn = await callWithNuxt(nuxt, useAuthState) + + const signOutConfig = config.endpoints.signOut + + let headers + let body + if (signOutConfig) { + headers = new Headers({ [config.token.headerName]: token.value } as HeadersInit) + // If the refresh provider is used, include the refreshToken in the body + if (config.refresh.isEnabled && ['post', 'put', 'patch', 'delete'].includes(signOutConfig.method.toLowerCase())) { + // This uses refresh token pointer as we are passing `refreshToken` + const signoutRequestRefreshTokenPointer = config.refresh.token.refreshRequestTokenPointer + body = objectFromJsonPointer(signoutRequestRefreshTokenPointer, refreshToken.value) + } + } - const headers = new Headers({ [config.token.headerName]: token.value } as HeadersInit) data.value = null rawToken.value = null + rawRefreshToken.value = null - const signOutConfig = config.endpoints.signOut let res - if (signOutConfig) { const { path, method } = signOutConfig - res = await _fetch(nuxt, path, { method, headers }) + res = await _fetch(nuxt, path, { method, headers, body }) } const { callbackUrl, redirect = true, external } = signOutOptions ?? {} @@ -81,7 +112,7 @@ const getSession: GetSessionFunc = async (getSessionO let token = tokenState.value // For cached responses, return the token directly from the cookie - token ??= formatToken(_internal.rawTokenCookie.value) + token ??= formatToken(_internal.rawTokenCookie.value, config) if (!token && !getSessionOptions?.force) { loading.value = false @@ -135,16 +166,80 @@ const signUp = async (credentials: Credentials, signInOptions?: SecondarySignInO return signIn(credentials, signInOptions) } +const refresh = async (getSessionOptions?: GetSessionOptions) => { + const nuxt = useNuxtApp() + const config = useTypedBackendConfig(useRuntimeConfig(), 'local') + + // Only refresh the session if the refresh logic is not enabled + if (!config.refresh.isEnabled) { + return getSession(getSessionOptions) + } + + const { path, method } = config.refresh.endpoint + const refreshRequestTokenPointer = config.refresh.token.refreshRequestTokenPointer + + const { refreshToken, token, rawToken, rawRefreshToken, lastRefreshedAt } = useAuthState() + + const headers = new Headers({ + [config.token.headerName]: token.value + } as HeadersInit) + + const response = await _fetch>(nuxt, path, { + method, + headers, + body: objectFromJsonPointer(refreshRequestTokenPointer, refreshToken.value) + }) + + // Extract the new token from the refresh response + const extractedToken = jsonPointerGet(response, config.token.signInResponseTokenPointer) + if (typeof extractedToken !== 'string') { + console.error( + `Auth: string token expected, received instead: ${JSON.stringify(extractedToken)}. ` + + `Tried to find token at ${config.token.signInResponseTokenPointer} in ${JSON.stringify(response)}` + ) + return + } + + if (!config.refresh.refreshOnlyToken) { + const refreshTokenPointer = config.refresh.token.signInResponseRefreshTokenPointer + const extractedRefreshToken = jsonPointerGet(response, refreshTokenPointer) + if (typeof extractedRefreshToken !== 'string') { + console.error( + `Auth: string token expected, received instead: ${JSON.stringify(extractedRefreshToken)}. ` + + `Tried to find refresh token at ${refreshTokenPointer} in ${JSON.stringify(response)}` + ) + return + } else { + rawRefreshToken.value = extractedRefreshToken + } + } + + rawToken.value = extractedToken + lastRefreshedAt.value = new Date() + + await nextTick() + return getSession(getSessionOptions) +} + +/** + * Returns an extended version of CommonUseAuthReturn with local-provider specific data + * + * @remarks + * The returned value `refreshToken` will always be `null` if `refresh.isEnabled` is `false` + */ interface UseAuthReturn extends CommonUseAuthReturn { signUp: typeof signUp token: Readonly> + refreshToken: Readonly> } + export const useAuth = (): UseAuthReturn => { const { data, status, lastRefreshedAt, - token + token, + refreshToken } = useAuthState() return { @@ -152,10 +247,11 @@ export const useAuth = (): UseAuthReturn => { data: readonly(data), lastRefreshedAt: readonly(lastRefreshedAt), token: readonly(token), + refreshToken: readonly(refreshToken), getSession, signIn, signOut, signUp, - refresh: getSession + refresh } } diff --git a/src/runtime/composables/local/useAuthState.ts b/src/runtime/composables/local/useAuthState.ts index 1c5b708e..b9822c5c 100644 --- a/src/runtime/composables/local/useAuthState.ts +++ b/src/runtime/composables/local/useAuthState.ts @@ -8,9 +8,17 @@ import { useRuntimeConfig, useCookie, useState, onMounted } from '#imports' // @ts-expect-error - #auth not defined import type { SessionData } from '#auth' -interface UseAuthStateReturn extends CommonUseAuthStateReturn { +/** + * 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: { @@ -24,6 +32,8 @@ export const useAuthState = (): UseAuthStateReturn => { const config = useTypedBackendConfig(useRuntimeConfig(), 'local') const commonAuthState = makeCommonAuthState() + const instance = getCurrentInstance() + // Re-construct state from cookie, also setup a cross-component sync via a useState hack, see https://github.com/nuxt/nuxt/issues/13020#issuecomment-1397282717 const _rawTokenCookie = useCookie(config.token.cookieName, { default: () => null, @@ -33,38 +43,59 @@ export const useAuthState = (): UseAuthStateReturn => { secure: config.token.secureCookieAttribute, httpOnly: config.token.httpOnlyCookieAttribute }) - const rawToken = useState('auth:raw-token', () => _rawTokenCookie.value) watch(rawToken, () => { _rawTokenCookie.value = rawToken.value }) - const token = computed(() => formatToken(rawToken.value)) - - const setToken = (newToken: string | null) => { + const token = computed(() => formatToken(rawToken.value, config)) + function setToken (newToken: string | null) { rawToken.value = newToken } - - const clearToken = () => { + function clearToken () { setToken(null) } - const schemeSpecificState = { - token, - rawToken - } - - const instance = getCurrentInstance() + // When the page is cached on a server, set the token on the client if (instance) { onMounted(() => { - // When the page is cached on a server, set the token on the client if (_rawTokenCookie.value && !rawToken.value) { setToken(_rawTokenCookie.value) } }) } + // Handle refresh token, for when refresh logic is enabled + const rawRefreshToken = useState('auth:raw-refresh-token', () => null) + if (config.refresh.isEnabled) { + const _rawRefreshTokenCookie = useCookie(config.refresh.token.cookieName, + { + default: () => null, + domain: config.refresh.token.cookieDomain, + maxAge: config.refresh.token.maxAgeInSeconds, + sameSite: config.refresh.token.sameSiteAttribute, + secure: config.refresh.token.secureCookieAttribute, + httpOnly: config.refresh.token.httpOnlyCookieAttribute + } + ) + 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(() => formatToken(rawRefreshToken.value, config)) + return { ...commonAuthState, - ...schemeSpecificState, + token, + rawToken, + refreshToken, + rawRefreshToken, setToken, clearToken, _internal: { diff --git a/src/runtime/composables/refresh/useAuth.ts b/src/runtime/composables/refresh/useAuth.ts deleted file mode 100644 index 01c31e37..00000000 --- a/src/runtime/composables/refresh/useAuth.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Ref } from 'vue' -import { callWithNuxt } from '#app' -import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../../helpers' -import { useAuth as useLocalAuth } from '../local/useAuth' -import { _fetch } from '../../utils/fetch' -import { getRequestURLWN } from '../../utils/callWithNuxt' -import { determineCallbackUrl } from '../../utils/url' -import type { SignOutFunc } from '../../types' -import { useAuthState } from './useAuthState' -import { - navigateTo, - nextTick, - readonly, - useNuxtApp, - useRuntimeConfig -} from '#imports' - -const signIn: ReturnType['signIn'] = async ( - credentials, - signInOptions, - signInParams -) => { - const nuxt = useNuxtApp() - const { getSession } = useLocalAuth() - const runtimeConfig = await callWithNuxt(nuxt, useRuntimeConfig) - const config = useTypedBackendConfig(runtimeConfig, 'refresh') - const { path, method } = config.endpoints.signIn - const response = await _fetch>(nuxt, path, { - method, - body: credentials, - params: signInParams ?? {} - }) - - const extractedToken = jsonPointerGet( - response, - config.token.signInResponseTokenPointer - ) - if (typeof extractedToken !== 'string') { - console.error( - `Auth: string token expected, received instead: ${JSON.stringify( - extractedToken - )}. Tried to find token at ${ - config.token.signInResponseTokenPointer - } in ${JSON.stringify(response)}` - ) - return - } - - const extractedRefreshToken = jsonPointerGet( - response, - config.refreshToken.signInResponseRefreshTokenPointer - ) - if (typeof extractedRefreshToken !== 'string') { - console.error( - `Auth: string token expected, received instead: ${JSON.stringify( - extractedRefreshToken - )}. Tried to find token at ${ - config.refreshToken.signInResponseRefreshTokenPointer - } in ${JSON.stringify(response)}` - ) - return - } - - const { rawToken, rawRefreshToken } = useAuthState() - rawToken.value = extractedToken - rawRefreshToken.value = extractedRefreshToken - - await nextTick(getSession) - - const { redirect = true } = signInOptions ?? {} - let { callbackUrl } = signInOptions ?? {} - if (typeof callbackUrl === 'undefined') { - callbackUrl = await determineCallbackUrl(runtimeConfig.public.auth, () => getRequestURLWN(nuxt)) - } - if (redirect) { - return navigateTo(callbackUrl) - } -} - -const refresh = async () => { - const nuxt = useNuxtApp() - const config = useTypedBackendConfig(useRuntimeConfig(), 'refresh') - const { path, method } = config.endpoints.refresh - const refreshRequestTokenPointer = config.refreshToken.refreshRequestTokenPointer - - const { getSession } = useLocalAuth() - const { refreshToken, token, rawToken, rawRefreshToken, lastRefreshedAt } = - useAuthState() - - const headers = new Headers({ - [config.token.headerName]: token.value - } as HeadersInit) - - const response = await _fetch>(nuxt, path, { - method, - headers, - body: objectFromJsonPointer(refreshRequestTokenPointer, refreshToken.value) - }) - - const extractedToken = jsonPointerGet( - response, - config.token.signInResponseTokenPointer - ) - if (typeof extractedToken !== 'string') { - console.error( - `Auth: string token expected, received instead: ${JSON.stringify( - extractedToken - )}. Tried to find token at ${ - config.token.signInResponseTokenPointer - } in ${JSON.stringify(response)}` - ) - return - } - - if (!config.refreshOnlyToken) { - const extractedRefreshToken = jsonPointerGet( - response, - config.refreshToken.signInResponseRefreshTokenPointer - ) - if (typeof extractedRefreshToken !== 'string') { - console.error( - `Auth: string token expected, received instead: ${JSON.stringify( - extractedRefreshToken - )}. Tried to find token at ${ - config.refreshToken.signInResponseRefreshTokenPointer - } in ${JSON.stringify(response)}` - ) - return - } else { - rawRefreshToken.value = extractedRefreshToken - } - } - - rawToken.value = extractedToken - lastRefreshedAt.value = new Date() - - await nextTick(getSession) -} - -const signOut: SignOutFunc = async (signOutOptions) => { - const nuxt = useNuxtApp() - const runtimeConfig = await callWithNuxt(nuxt, useRuntimeConfig) - const config = useTypedBackendConfig(runtimeConfig, 'refresh') - const { data, rawToken, token, rawRefreshToken, refreshToken } = await callWithNuxt( - nuxt, - useAuthState - ) - - const headers = new Headers({ - [config.token.headerName]: token.value - } as HeadersInit) - - const refreshRequestTokenPointer = config.refreshToken.refreshRequestTokenPointer - const body = objectFromJsonPointer(refreshRequestTokenPointer, refreshToken.value) - - data.value = null - rawToken.value = null - rawRefreshToken.value = null - - const signOutConfig = config.endpoints.signOut - let res - - if (signOutConfig) { - const { path, method } = config.endpoints.signOut as { - path: string; - method: - | 'get' - | 'head' - | 'patch' - | 'post' - | 'put' - | 'delete' - | 'connect' - | 'options' - | 'trace'; - } - res = await _fetch(nuxt, path, { method, headers, body: method.toLowerCase() === 'post' ? body : undefined }) - } - - const { callbackUrl, redirect = true } = signOutOptions ?? {} - if (redirect) { - await navigateTo(callbackUrl ?? (await getRequestURLWN(nuxt))) - } - - return res -} - -type UseAuthReturn = ReturnType & { - refreshToken: Readonly>; -}; - -export const useAuth = (): UseAuthReturn => { - const localAuth = useLocalAuth() - // overwrite the local signIn & signOut Function - localAuth.signIn = signIn - localAuth.signOut = signOut - - const { refreshToken } = useAuthState() - - return { - ...localAuth, - refreshToken: readonly(refreshToken), - refresh - } -} diff --git a/src/runtime/composables/refresh/useAuthState.ts b/src/runtime/composables/refresh/useAuthState.ts deleted file mode 100644 index 5cef39c6..00000000 --- a/src/runtime/composables/refresh/useAuthState.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { computed, watch, type ComputedRef } from 'vue' -import { type CookieRef } from '#app' -import { useTypedBackendConfig } from '../../helpers' -import { useAuthState as useLocalAuthState } from '../local/useAuthState' -import { useRuntimeConfig, useCookie, useState } from '#imports' - -type UseAuthStateReturn = ReturnType & { - rawRefreshToken: CookieRef; - refreshToken: ComputedRef; -}; - -export const useAuthState = (): UseAuthStateReturn => { - const config = useTypedBackendConfig(useRuntimeConfig(), 'refresh') - const localAuthState = useLocalAuthState() - // 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 _rawRefreshTokenCookie = useCookie( - config.refreshToken.cookieName, - { - default: () => null, - domain: config.refreshToken.cookieDomain, - maxAge: config.refreshToken.maxAgeInSeconds, - sameSite: 'lax', - secure: config.refreshToken.secureCookieAttribute, - httpOnly: config.refreshToken.httpOnlyCookieAttribute - } - ) - - const rawRefreshToken = useState( - 'auth:raw-refresh-token', - () => _rawRefreshTokenCookie.value - ) - - watch(rawRefreshToken, () => { - _rawRefreshTokenCookie.value = rawRefreshToken.value - }) - - const refreshToken = computed(() => { - if (rawRefreshToken.value === null) { - return null - } - return rawRefreshToken.value - }) - - const schemeSpecificState = { - refreshToken, - rawRefreshToken - } - - return { - ...localAuthState, - ...schemeSpecificState - } -} -export default useAuthState diff --git a/src/runtime/helpers.ts b/src/runtime/helpers.ts index 5ef69bcc..5641e747 100644 --- a/src/runtime/helpers.ts +++ b/src/runtime/helpers.ts @@ -1,7 +1,7 @@ // TODO: This should be merged into `./utils` import { parseURL } from 'ufo' import type { DeepRequired } from 'ts-essentials' -import type { SupportedAuthProviders, AuthProviders } from './types' +import type { ProviderAuthjs, ProviderLocal, SupportedAuthProviders } from './types' import { useRuntimeConfig } from '#imports' export const isProduction = process.env.NODE_ENV === 'production' @@ -21,22 +21,30 @@ export const getOriginAndPathnameFromURL = (url: string) => { } } +// We use `DeepRequired` here because options are actually enriched using `defu` +// but due to a build error we can't use `DeepRequired` inside runtime config definition. +type RuntimeConfig = ReturnType +export type ProviderAuthjsResolvedConfig = DeepRequired +export type ProviderLocalResolvedConfig = DeepRequired + +export function useTypedBackendConfig(runtimeConfig: RuntimeConfig, type: 'authjs'): ProviderAuthjsResolvedConfig +export function useTypedBackendConfig(runtimeConfig: RuntimeConfig, type: 'local'): ProviderLocalResolvedConfig /** * Get the backend configuration from the runtime config in a typed manner. * * @param runtimeConfig The runtime config of the application - * @param type Backend type to be enforced (e.g.: `local`,`refresh` or `authjs`) + * @param type Backend type to be enforced (e.g.: `local` or `authjs`) */ -export const useTypedBackendConfig = ( +export function useTypedBackendConfig ( runtimeConfig: ReturnType, - _type: T -): Extract, { type: T }> => { - return runtimeConfig.public.auth.provider as Extract< - DeepRequired, - { type: T } - > - // TODO: find better solution to throw errors, when using sub-configs - // throw new Error('RuntimeError: Type must match at this point') + type: T +): ProviderAuthjsResolvedConfig | ProviderLocalResolvedConfig { + const provider = runtimeConfig.public.auth.provider + if (provider.type === type) { + return provider as DeepRequired + } + + throw new Error('RuntimeError: Type must match at this point') } /** diff --git a/src/runtime/middleware/auth.ts b/src/runtime/middleware/auth.ts index 97c28b0b..6cae2702 100644 --- a/src/runtime/middleware/auth.ts +++ b/src/runtime/middleware/auth.ts @@ -66,8 +66,8 @@ export default defineNuxtRouteMiddleware((to) => { return } - // We do not want to block the login page when the local/refresh provider is used - if (authConfig.provider?.type === 'local' || authConfig.provider?.type === 'refresh') { + // We do not want to block the login page when the local provider is used + if (authConfig.provider?.type === 'local') { const loginRoute: string | undefined = authConfig.provider?.pages?.login if (loginRoute && loginRoute === to.path) { return diff --git a/src/runtime/plugins/refresh-token.server.ts b/src/runtime/plugins/refresh-token.server.ts index 1381b446..872125c2 100644 --- a/src/runtime/plugins/refresh-token.server.ts +++ b/src/runtime/plugins/refresh-token.server.ts @@ -1,7 +1,7 @@ import type { DeepRequired } from 'ts-essentials' import { _fetch } from '../utils/fetch' import { jsonPointerGet, objectFromJsonPointer, useTypedBackendConfig } from '../helpers' -import type { ProviderLocalRefresh } from '../types' +import type { ProviderLocal } from '../types' import { defineNuxtPlugin, useAuthState, useRuntimeConfig } from '#imports' export default defineNuxtPlugin({ @@ -13,12 +13,12 @@ export default defineNuxtPlugin({ if (refreshToken.value && token.value) { const config = nuxtApp.$config.public.auth - const configToken = useTypedBackendConfig(useRuntimeConfig(), 'refresh') + const configToken = useTypedBackendConfig(useRuntimeConfig(), 'local') - const provider = config.provider as DeepRequired + const provider = config.provider as DeepRequired - const { path, method } = provider.endpoints.refresh - const refreshRequestTokenPointer = provider.refreshToken.refreshRequestTokenPointer + const { path, method } = provider.refresh.endpoint + const refreshRequestTokenPointer = provider.refresh.token.refreshRequestTokenPointer // include header in case of auth is required to avoid 403 rejection const headers = new Headers({ @@ -48,17 +48,17 @@ export default defineNuxtPlugin({ } // check if refreshTokenOnly - if (!configToken.refreshOnlyToken) { + if (!configToken.refresh.refreshOnlyToken) { const extractedRefreshToken = jsonPointerGet( response, - provider.refreshToken.signInResponseRefreshTokenPointer + provider.refresh.token.signInResponseRefreshTokenPointer ) if (typeof extractedRefreshToken !== 'string') { console.error( `Auth: string token expected, received instead: ${JSON.stringify( extractedRefreshToken )}. Tried to find token at ${ - provider.refreshToken.signInResponseRefreshTokenPointer + provider.refresh.token.signInResponseRefreshTokenPointer } in ${JSON.stringify(response)}` ) return diff --git a/src/runtime/types.ts b/src/runtime/types.ts index aa3b4c2b..851bb251 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -56,18 +56,15 @@ export type SessionDataObject = { /** * Available `nuxt-auth` authentication providers. */ -export type SupportedAuthProviders = 'authjs' | 'local' | 'refresh'; +export type SupportedAuthProviders = 'authjs' | 'local' /** * Configuration for the `local`-provider. */ export type ProviderLocal = { /** - * Uses the `local` provider to facilitate authentication. Currently, two providers exclusive are supported: - * - `authjs`: `next-auth` / `auth.js` based OAuth, Magic URL, Credential provider for non-static applications - * - `local` or 'refresh': Username and password provider with support for static-applications - * - * Read more here: https://sidebase.io/nuxt-auth/v0.6/getting-started + * Uses the `local` provider to facilitate authentication. + * Read more here: https://auth.sidebase.io/guide/local/quick-start */ type: Extract; /** @@ -161,7 +158,8 @@ export type ProviderLocal = { */ maxAgeInSeconds?: number; /** - * The cookie sameSite policy. See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7 + * The cookie sameSite policy. + * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7 * * @default 'lax' * @example 'strict' @@ -169,6 +167,7 @@ export type ProviderLocal = { sameSiteAttribute?: boolean | 'lax' | 'strict' | 'none' | undefined; /** * Whether to set the secure flag on the cookie. This is useful when the application is served over HTTPS. + * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.5 * * @default false * @example true @@ -184,6 +183,7 @@ export type ProviderLocal = { cookieDomain?: string; /** * Whether to set the httpOnly flag on the cookie. + * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.6 * * @default false * @example true @@ -215,98 +215,107 @@ export type ProviderLocal = { */ dataResponsePointer?: string; }; -}; - -/** - * Configuration for the `refresh`-provider an extended version of the local provider. - */ -export type ProviderLocalRefresh = Omit & { /** - * Uses the `authjs` provider to facilitate authentication. Currently, two providers exclusive are supported: - * - `authjs`: `next-auth` / `auth.js` based OAuth, Magic URL, Credential provider for non-static applications - * - `local` or 'refresh': Username and password provider with support for static-applications - * - * Read more here: https://sidebase.io/nuxt-auth/v0.6/getting-started + * 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. */ - type: Extract; - endpoints?: { - /** - * What method and path to call to perform the sign-in. This endpoint must return a token that can be used to authenticate subsequent requests. - * - * @default { path: '/refresh', method: 'post' } - */ - refresh?: { path?: string; method?: RouterMethod }; - }; - /** - * When refreshOnlyToken is set, only the token will be refreshed - * - * @default true - */ - refreshOnlyToken?: boolean; - - refreshToken?: { - /** - * How to extract the authentication-token from the sign-in response. - * - * E.g., setting this to `/refreshToken/bearer` and returning an object like `{ refreshToken: { bearer: 'THE_AUTH_TOKEN' }, timestamp: '2023' }` from the `signIn` endpoint will - * result in `nuxt-auth` extracting and storing `THE_AUTH_TOKEN`. - * - * This follows the JSON Pointer standard, see its RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901 - * - * @default '/refreshToken' Access the `refreshToken` property of the sign-in response object - * @example / Access the root of the sign-in response object, useful when your endpoint returns a plain, non-object string as the token - */ - signInResponseRefreshTokenPointer?: string; - /** - * How to do a fetch for the refresh token. - * - * This is especially useful when you have an external backend signing tokens. Refer to this issue to get more information: https://github.com/sidebase/nuxt-auth/issues/635. - * - * ### Example - * Setting this to `/refresh/token` would make Nuxt Auth send the `POST /api/auth/refresh` with the following BODY: `{ "refresh": { "token": "..." } } - * - * ### Notes - * This follows the JSON Pointer standard, see its RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901 - * - * @default '/refreshToken' - */ - refreshRequestTokenPointer?: string; + refresh?: { /** - * It refers to the name of the property when it is stored in a cookie. + * Whether the refresh logic of the local provider is active * - * @default auth.refresh-token - * @example auth._refresh-token + * @default false */ - cookieName?: string; + isEnabled?: boolean, /** - * Maximum age to store the authentication token for. After the expiry time the token is automatically deleted on the application side, i.e., in the users' browser. + * What method and path to call to perform the sign-in. This endpoint must return a token that can be used to authenticate subsequent requests. * - * Note: Your backend may reject / expire the token earlier / differently. + * @default { path: '/refresh', method: 'post' } */ - maxAgeInSeconds?: number; + endpoint?: { path?: string; method?: RouterMethod }; /** - * Whether to set the secure flag on the cookie. This is useful when the application is served over HTTPS. + * When refreshOnlyToken is set to `true`, only the token will be updated when the refresh endpoint is called. + * When refreshOnlyToken is set to `false`, the token and refreshToken will be updated when the refresh endpoint is called. * - * @default false - * @example true + * @default true */ - secureCookieAttribute?: boolean; + refreshOnlyToken?: boolean; /** - * The cookie domain. - * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.3 - * - * @default '' - * @example 'sidebase.io' + * Settings for the refresh-token that `nuxt-auth` receives from the `signIn` endpoint that is used for the `refresh` endpoint. */ - cookieDomain?: string; - /** - * Whether to set the httpOnly flag on the cookie. - * - * @default false - * @example true - */ - httpOnlyCookieAttribute?: boolean; - }; + token?: { + /** + * How to extract the authentication-token from the sign-in response. + * + * E.g., setting this to `/refreshToken/bearer` and returning an object like `{ refreshToken: { bearer: 'THE_AUTH_TOKEN' }, timestamp: '2023' }` from the `signIn` endpoint will + * result in `nuxt-auth` extracting and storing `THE_AUTH_TOKEN`. + * + * This follows the JSON Pointer standard, see its RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901 + * + * @default '/refreshToken' Access the `refreshToken` property of the sign-in response object + * @example / Access the root of the sign-in response object, useful when your endpoint returns a plain, non-object string as the token + */ + signInResponseRefreshTokenPointer?: string; + /** + * How to do a fetch for the refresh token. + * + * This is especially useful when you have an external backend signing tokens. Refer to this issue to get more information: https://github.com/sidebase/nuxt-auth/issues/635. + * + * ### Example + * Setting this to `/refresh/token` would make Nuxt Auth send the `POST /api/auth/refresh` with the following BODY: `{ "refresh": { "token": "..." } } + * + * ### Notes + * This follows the JSON Pointer standard, see its RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901 + * + * @default '/refreshToken' + */ + refreshRequestTokenPointer?: string; + /** + * It refers to the name of the property when it is stored in a cookie. + * + * @default 'auth.refresh-token' + * @example 'auth._refresh-token' + */ + cookieName?: string; + /** + * Maximum age to store the authentication token for. After the expiry time the token is automatically deleted on the application side, i.e., in the users' browser. + * + * Note: Your backend may reject / expire the token earlier / differently. + */ + maxAgeInSeconds?: number; + /** + * The cookie sameSite policy. + * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7 + * + * @default 'lax' + * @example 'strict' + */ + sameSiteAttribute?: boolean | 'lax' | 'strict' | 'none' | undefined; + /** + * Whether to set the secure flag on the cookie. This is useful when the application is served over HTTPS. + * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.5 + * + * @default false + * @example true + */ + secureCookieAttribute?: boolean; + /** + * The cookie domain. + * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.3 + * + * @default '' + * @example 'sidebase.io' + */ + cookieDomain?: string; + /** + * Whether to set the httpOnly flag on the cookie. + * See the specification here: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.6 + * + * @default false + * @example true + */ + httpOnlyCookieAttribute?: boolean; + } + } }; /** @@ -314,11 +323,8 @@ export type ProviderLocalRefresh = Omit & { */ export type ProviderAuthjs = { /** - * Uses the `authjs` provider to facilitate autnetication. Currently, two providers exclusive are supported: - * - `authjs`: `next-auth` / `auth.js` based OAuth, Magic URL, Credential provider for non-static applications - * - `local` or `refresh`: Username and password provider with support for static-applications - * - * Read more here: https://sidebase.io/nuxt-auth/v0.6/getting-started + * Uses the `authjs` provider to facilitate authentication. + * Read more here: https://auth.sidebase.io/guide/authjs/quick-start */ type: Extract; /** @@ -347,7 +353,6 @@ export type ProviderAuthjs = { export type AuthProviders = | ProviderAuthjs | ProviderLocal - | ProviderLocalRefresh; export interface RefreshHandler { /** @@ -498,7 +503,6 @@ export interface RouteOptions { } // Common useAuthStatus & useAuth return-types - export type SessionLastRefreshedAt = Date | undefined; export type SessionStatus = 'authenticated' | 'unauthenticated' | 'loading'; type WrappedSessionData = Ref; @@ -524,7 +528,6 @@ export interface CommonUseAuthStateReturn { } // Common `useAuth` method-types - export interface SecondarySignInOptions extends Record { /** * Specify to which URL the user will be redirected after signing in. Defaults to the page URL the sign-in is initiated from. diff --git a/src/runtime/utils/local.ts b/src/runtime/utils/local.ts index 062f021f..b2b5bde1 100644 --- a/src/runtime/utils/local.ts +++ b/src/runtime/utils/local.ts @@ -1,10 +1,7 @@ -import { useTypedBackendConfig } from '../helpers' -import { useRuntimeConfig } from '#imports' +import type { ProviderLocalResolvedConfig } from '../helpers' -export const formatToken = (token: string | null) => { - const config = useTypedBackendConfig(useRuntimeConfig(), 'local') - - if (token === null) { +export function formatToken (token: string | null | undefined, config: ProviderLocalResolvedConfig): string | null { + if (token === null || token === undefined) { return null } return config.token.type.length > 0 ? `${config.token.type} ${token}` : token diff --git a/src/runtime/utils/refreshHandler.ts b/src/runtime/utils/refreshHandler.ts index 5b8835f1..15c80908 100644 --- a/src/runtime/utils/refreshHandler.ts +++ b/src/runtime/utils/refreshHandler.ts @@ -41,8 +41,9 @@ export class DefaultRefreshHandler implements RefreshHandler { }, intervalTime) } - if (this.runtimeConfig.provider.type === 'refresh') { - const intervalTime = this.runtimeConfig.provider.token.maxAgeInSeconds! * 1000 + const provider = this.runtimeConfig.provider + if (provider.type === 'local' && provider.refresh.isEnabled && provider.refresh.token?.maxAgeInSeconds) { + const intervalTime = provider.refresh.token.maxAgeInSeconds * 1000 this.refreshTokenIntervalTimer = setInterval(() => { if (this.auth?.refreshToken.value) { diff --git a/tsconfig.json b/tsconfig.json index 2a7b6fa3..c4b3934a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { // Note: we need to explicitly extend one of the playgrounds here in order to have a nice and working typecheckk, otherwise `nuxt`-types are going to be unknown - "extends": "./playground-refresh/.nuxt/tsconfig.json", + "extends": "./playground-local/.nuxt/tsconfig.json", "exclude": ["../docs"], "include": ["src/"] }