diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ab2ac7c2..4626adb7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -67,7 +67,36 @@ jobs: # start prod-app and curl from it - run: "timeout 60 pnpm start & (sleep 45 && curl --fail localhost:3000)" + test-playground-refresh: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./playground-refresh + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 16.14.2 + uses: actions/setup-node@v3 + with: + node-version: 16.14.2 + + - uses: pnpm/action-setup@v2 + name: Install pnpm + id: pnpm-install + with: + version: 8 + + # Install deps + - run: pnpm i + + # Check building + - run: pnpm build + + # start prod-app and curl from it + - run: "timeout 60 pnpm start & (sleep 45 && curl --fail localhost:$PORT)" + env: + AUTH_ORIGIN: "http://localhost:3002" + PORT: 3002 test-playground-authjs: runs-on: ubuntu-latest @@ -97,5 +126,5 @@ jobs: # start prod-app and curl from it - run: "timeout 60 pnpm start & (sleep 45 && curl --fail localhost:$PORT)" env: - AUTH_ORIGIN: 'http://localhost:3001' + AUTH_ORIGIN: "http://localhost:3001" PORT: 3001 diff --git a/docs/content/1.getting-started/3.quick-start.md b/docs/content/1.getting-started/3.quick-start.md index e2be91f0..cb0b1451 100644 --- a/docs/content/1.getting-started/3.quick-start.md +++ b/docs/content/1.getting-started/3.quick-start.md @@ -91,6 +91,53 @@ and return a token that can be used to authenticate future requests in the respo } ``` +### Provider: `refresh` + +The refresh provider does not require any additional steps, as it relies on an already existing backend. By default, the `refresh` provider will try to reach this backend using the following default-configuration: +```ts +{ + baseURL: '/api/auth', + 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' }, + } +} +``` + +So when you call the `signIn` method, the endpoint `/api/auth/login` will be hit with the `username` and `password` you pass as a body-payload. You likely have to modify these parameters to fit to your backend - you can adjust these parameters in your `nuxt.config.ts` using the options [specified here](/nuxt-auth/v0.6/configuration/nuxt-config). + +Note: The backend can also be in the same Nuxt 3 application, e.g., have a look at this example in the `nuxt-auth` repository: +- [full nuxt app](https://github.com/sidebase/nuxt-auth/tree/main/playground-refresh) + - its [backend](https://github.com/sidebase/nuxt-auth/tree/main/playground-refresh/server/api/auth) + - its [`nuxt.config.ts`](https://github.com/sidebase/nuxt-auth/blob/main/playground-refresh/nuxt.config.ts) + +::alert{type="info"} +The linked example-implementation only serves as a starting-point and is not considered to be secure. +:: + +The backend must accept a request with a body like: +```ts +{ + username: 'bernd@sidebase.io', + password: 'hunter2' +} +``` + +and return a token that can be used to authenticate future requests in the response body, e.g., like: +```ts +{ + tokens: { + accessToken: 'eyBlaBlub' + refreshToken: 'eyBlaubwww' + } +} +``` + +So when you call the `refresh` method, the endpoint `/api/auth/refresh` will be hit with the `refreshToken` you pass as a body-payload. You likely have to modify these parameters to fit to your backend - you can adjust these parameters in your `nuxt.config.ts` using the options [specified here](/nuxt-auth/v0.6/configuration/nuxt-config). + ## Finishing up That's it! You can now use all user-related functionality, for example: @@ -98,13 +145,15 @@ That's it! You can now use all user-related functionality, for example: ::code-group ```ts [Application side] // file: e.g ~/pages/login.vue -const { status, data, signIn, signOut } = useAuth() +const { status, data, signIn, signOut, refresh } = useAuth() status.value // Session status: `unauthenticated`, `loading`, `authenticated` data.value // Session data, e.g., expiration, user.email, ... await signIn() // Sign in the user +await refresh() // Refresh the token await signOut() // Sign out the user + ``` ```ts [authjs: Server side] // file: e.g: ~/server/api/session.get.ts diff --git a/docs/content/2.configuration/2.nuxt-config.md b/docs/content/2.configuration/2.nuxt-config.md index d79cba6d..4f7dd152 100644 --- a/docs/content/2.configuration/2.nuxt-config.md +++ b/docs/content/2.configuration/2.nuxt-config.md @@ -228,6 +228,162 @@ type ProviderLocal = { */ sessionDataType?: SessionDataObject, } + +``` +```ts [AuthProviders - refresh] +/** + * Configuration for the `refresh`-provider. + */ +type ProviderRefresh = { + /** + * Uses the `refresh` 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`: Username and password provider with support for static-applications + * - `refresh`: Username and password provider with support for static-applications with refresh token logic + * Read more here: https://sidebase.io/nuxt-auth/v0.6/getting-started + */ + type: Extract + /** + * Endpoints to use for the different methods. `nuxt-auth` will use this and the root-level `baseURL` to create the final request. E.g.: + * - `baseURL=/api/auth`, `path=/login` will result in a request to `/api/auth/login` + * - `baseURL=http://localhost:5000/_authenticate`, `path=/sign-in` will result in a request to `http://localhost:5000/_authenticate/sign-in` + */ + 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: '/login', method: 'post' } + */ + signIn?: { path?: string, method?: RouterMethod }, + /** + * What method and path to call to perform the sign-out. Set to false to disable. + * + * @default { path: '/logout', method: 'post' } + */ + signOut?: { path?: string, method?: RouterMethod } | false, + /** + * What method and path to call to perform the sign-up. + * + * @default { path: '/register', method: 'post' } + */ + signUp?: { path?: string, method?: RouterMethod }, + /** + * What method and path to call to fetch user / session data from. `nuxt-auth` will send the token received upon sign-in as a header along this request to authenticate. + * + * Refer to the `token` configuration to configure how `nuxt-auth` uses the token in this request. By default it will be send as a bearer-authentication header like so: `Authentication: Bearer eyNDSNJDASNMDSA....` + * + * @default { path: '/session', method: 'get' } + * @example { path: '/user', method: 'get' } + */ + getSession?: { path?: string, method?: RouterMethod }, + /** + * What method and path to call to perform the refresh. + * + * @default { path: '/refresh', method: 'post' } + */ + refresh?: { path?: string, method?: RouterMethod }, + }, + /** + * When refreshOnlyToken is set, only the token will be refreshed + * + * + */ + refreshOnlyToken?: true; + /** + * 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. This page will also not be blocked by the global middleware. + * + * @default '/login' + */ + login?: string + }, + /** + * Settings for the authentication-token that `nuxt-auth` receives from the `signIn` endpoint and that can be used to authenticate subsequent requests. + */ + token?: { + /** + * How to extract the authentication-token from the sign-in response. + * + * E.g., setting this to `/token/bearer` and returning an object like `{ token: { 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 it's RFC6901 here: https://www.rfc-editor.org/rfc/rfc6901 + * + * @default /token Access the `token` 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 + */ + signInResponseTokenPointer?: string + /** + * Header type to be used in requests. This in combination with `headerName` is used to construct the final authentication-header `nuxt-auth` uses, e.g, for requests via `getSession`. + * + * @default Bearer + * @example Beer + */ + type?: string, + /** + * Header name to be used in requests that need to be authenticated, e.g., to be used in the `getSession` request. + * + * @default Authorization + * @example Auth + */ + headerName?: 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. + * + * @default 1800 + * @example 60 * 60 * 24 + */ + maxAgeInSeconds?: number, + /** + * The cookie sameSite policy. Can be used as a form of csrf forgery protection. If set to `strict`, the cookie will only be passed with requests to the same 'site'. Typically, this includes subdomains. So, a sameSite: strict cookie set by app.mysite.com will be passed to api.mysite.com, but not api.othersite.com. + * + * 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, + }, + /** + * Settings for the authentication-refreshToken that `nuxt-auth` receives from the `signIn` endpoint and that can be used to authenticate subsequent requests. + */ + refreshToken?: { + /** + * How to extract the authentication-refreshToken from the sign-in response. + * + * E.g., setting this to `/token/refreshToken` and returning an object like `{ token: { refreshToken: 'THE_REFRESH__TOKEN' }, timestamp: '2023' }` from the `signIn` endpoint will + * result in `nuxt-auth` extracting and storing `THE_REFRESH__TOKEN`. + * + * This follows the JSON Pointer standard, see it's 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 refreshToken + */ + signInResponseRefreshTokenPointer?: 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 refreshToken earlier / differently. + * + * @default 1800 + * @example 60 * 60 * 24 + */ + maxAgeInSeconds?: number, + }, + /** + * Define an interface for the session data object that `nuxt-auth` expects to receive from the `getSession` endpoint. + * + * @default { id: 'string | number' } + * @example { id: 'string', name: 'string', email: 'string' } + * @advanced_array_example { id: 'string', email: 'string', name: 'string', role: 'admin | guest | account', subscriptions: "{ id: number, status: 'ACTIVE' | 'INACTIVE' }[]" } + */ + sessionDataType?: SessionDataObject, +} ``` ```ts [SessionConfig] /** diff --git a/docs/content/3.application-side/2.session-access-and-management.md b/docs/content/3.application-side/2.session-access-and-management.md index 6ecb38a3..232e0838 100644 --- a/docs/content/3.application-side/2.session-access-and-management.md +++ b/docs/content/3.application-side/2.session-access-and-management.md @@ -91,6 +91,56 @@ await signIn(credentials, { callbackUrl: 'https://sidebase.io', external: true } // Trigger a sign-out await signOut() +// Trigger a sign-out and send the user to the sign-out page afterwards +await signOut({ callbackUrl: '/signout' }) +``` +```ts [refresh] +const { + status, + data, + token, + lastRefreshedAt, + getSession, + signUp, + signIn, + signOut, + refresh, + refreshToken +} = useAuth() + +// Session status, either `unauthenticated`, `loading`, `authenticated` +status.value + +// Session data, either `undefined` (= authentication not attempted), `null` (= user unauthenticated), or session / user data your `getSession`-endpoint returns +data.value + +// The fetched token that can be used to authenticate future requests. E.g., a JWT-Bearer token like so: `Bearer eyDFSJKLDAJ0-3249PPRFK3P5234SDFL;AFKJlkjdsjd.dsjlajhasdji89034` +token.value + +// The fetched refreshToken that can be used to token . E.g., a refreshToken like so: `eyDFSJKLDAJ0-3249PPRFK3P5234SDFL;AFKJlkjdsjd.dsjlajhasdji89034` +refreshToken.value + +// Time at which the session was last refreshed, either `undefined` if no refresh was attempted or a `Date`-object of the time the refresh happened +lastRefreshedAt.value + +// Get / Reload the current session from the server, pass `{ required: true }` to force a login if no session exists +await getSession() + +// Trigger a sign-in, where `credentials` are the credentials your sign-in endpoint expected, e.g. `{ username: 'bernd', password: 'hunter2' }` +await signIn(credentials) + +// Trigger a sign-in with a redirect afterwards +await signIn(credentials, { callbackUrl: '/protected' }) + +// Trigger a sign-in with a redirect afterwards to an external page (if set, this will cause a hard refresh of the page) +await signIn(credentials, { callbackUrl: 'https://sidebase.io', external: true }) + +// Trigger a refresh, this will set token to new value +await refresh() + +// Trigger a sign-out +await signOut() + // Trigger a sign-out and send the user to the sign-out page afterwards await signOut({ callbackUrl: '/signout' }) ``` @@ -168,6 +218,14 @@ await signIn(credentials, { callbackUrl: '/protected' }) await signOut(credentials, { callbackUrl: '/protected' }) +await getSession(credentials, { callbackUrl: '/protected' }) +``` +```ts [refresh] +const credentials = { username: 'bernd', password: 'hunter2' } +await signIn(credentials, { callbackUrl: '/protected' }) + +await signOut(credentials, { callbackUrl: '/protected' }) + await getSession(credentials, { callbackUrl: '/protected' }) ``` :: @@ -233,6 +291,51 @@ setToken('new token') // Helper method to quickly delete the token cookie (alias for rawToken.value = null) clearToken() ``` + +```ts [refresh] +const { + status, + loading, + data, + lastRefreshedAt, + token, + rawToken, + setToken, + clearToken, + rawRefreshToken, + refreshToken +} = useAuthState() + +// Session status, either `unauthenticated`, `loading`, `authenticated` +status.value + +// Whether any http request is still pending +loading.value + +// Session data, either `undefined` (= authentication not attempted), `null` (= user unauthenticated), or session / user data your `getSession`-endpoint returns +data.value + +// Time at which the session was last refreshed, either `undefined` if no refresh was attempted or a `Date`-object of the time the refresh happened +lastRefreshedAt.value + +// The fetched token that can be used to authenticate future requests. E.g., a JWT-Bearer token like so: `Bearer eyDFSJKLDAJ0-3249PPRFK3P5234SDFL;AFKJlkjdsjd.dsjlajhasdji89034` +token.value + +// The fetched refreshToken that can be used to refresh the Token with refresh() methode. +refreshToken.value + +// Cookie that containes the raw fetched token string. This token won't contain any modification or prefixes like `Bearer` or any other. +rawToken.value + +// Cookie that containes the raw fetched refreshToken string. +rawRefreshToken.value + +// Helper method to quickly set a new token (alias for rawToken.value = 'xxx') +setToken('new token') + +// Helper method to quickly delete the token and refresh Token cookie (alias for rawToken.value = null and rawRefreshToken.value = null) +clearToken() +``` :: ::alert{type="warning"} diff --git a/docs/content/3.application-side/3.custom-sign-in-page.md b/docs/content/3.application-side/3.custom-sign-in-page.md index 2100c45c..8b8deff0 100644 --- a/docs/content/3.application-side/3.custom-sign-in-page.md +++ b/docs/content/3.application-side/3.custom-sign-in-page.md @@ -105,9 +105,9 @@ const mySignInHandler = async ({ username, password }: { username: string, passw Then call the `mySignInHandler({ username, password })` on login instead of the default `signIn(...)` method. You can find [all possible errors here](https://github.com/nextauthjs/next-auth/blob/aad0b8db0e8a163b3c3ae7dec3e9158e20d368f4/packages/next-auth/src/core/pages/signin.tsx#L4-L19). This file also contains the default error-messages that `nuxt-auth` would show to the user if you would not handle the error manually using `redirect: false`. -## Provider: `local` +## Provider: `local or refresh` -The only way to use the local provider does not come with a pre-made login page, so you will have to build one yourself. To do so: +The only way to use the local and refresh provider does not come with a pre-made login page, so you will have to build one yourself. To do so: 1. Create the page, e.g., `pages/login.vue` 2. Call the `signIn` method with the username and password your user has to enter on this page 3. Disable the page protection, if you have the global middleware enabled diff --git a/docs/content/4.server-side/4.rest-api.md b/docs/content/4.server-side/4.rest-api.md index d44fd7a8..86a6f6cf 100644 --- a/docs/content/4.server-side/4.rest-api.md +++ b/docs/content/4.server-side/4.rest-api.md @@ -10,6 +10,7 @@ All endpoints that NextAuth.js supports are also supported by `nuxt-auth`: |--------------------------------|:-------------| | `${basePath}/signin` | `GET` | | `${basePath}/signin/:provider` | `POST` | +| `${basePath}/refresh/:provider` | `POST` | | `${basePath}/callback/:provider` | `GET` `POST` | | `${basePath}/signout` | `GET` `POST` | | `${basePath}/session` | `GET` | diff --git a/package.json b/package.json index 25718869..33dcacf5 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,9 @@ "prepack": "nuxt-module-build", "build": "nuxi build", "lint": "eslint . --max-warnings=0", - "clean": "rm -rf playground-authjs/.nuxt playground-local/.nuxt dist .nuxt", + "clean": "rm -rf playground-authjs/.nuxt playground-local/.nuxt playground-refresh/.nuxt dist .nuxt", "typecheck": "nuxi prepare playground-local && tsc --noEmit", + "typecheck:refresh": "nuxi prepare playground-refresh && tsc --noEmit", "dev:prepare": "nuxt-module-build --stub" }, "dependencies": { diff --git a/playground-refresh/app.vue b/playground-refresh/app.vue new file mode 100644 index 00000000..ed9bfec2 --- /dev/null +++ b/playground-refresh/app.vue @@ -0,0 +1,62 @@ + + + diff --git a/playground-refresh/nuxt.config.ts b/playground-refresh/nuxt.config.ts new file mode 100644 index 00000000..13721118 --- /dev/null +++ b/playground-refresh/nuxt.config.ts @@ -0,0 +1,30 @@ +export default defineNuxtConfig({ + 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' + } + }, + globalAppMiddleware: { + isEnabled: true + } + } +}) diff --git a/playground-refresh/package.json b/playground-refresh/package.json new file mode 100644 index 00000000..62803999 --- /dev/null +++ b/playground-refresh/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "name": "nuxt-auth-playground-local", + "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" + }, + "dependencies": { + "jsonwebtoken": "^9.0.0", + "zod": "^3.21.4" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.1", + "eslint": "^8.37.0", + "nuxt": "^3.4.2", + "typescript": "^5.0.3", + "vue-tsc": "^1.2.0" + } +} diff --git a/playground-refresh/pages/always-unprotected.vue b/playground-refresh/pages/always-unprotected.vue new file mode 100644 index 00000000..3d15dc73 --- /dev/null +++ b/playground-refresh/pages/always-unprotected.vue @@ -0,0 +1,10 @@ + + + diff --git a/playground-refresh/pages/guest.vue b/playground-refresh/pages/guest.vue new file mode 100644 index 00000000..c7a31974 --- /dev/null +++ b/playground-refresh/pages/guest.vue @@ -0,0 +1,15 @@ + + + diff --git a/playground-refresh/pages/index.vue b/playground-refresh/pages/index.vue new file mode 100644 index 00000000..c60d174e --- /dev/null +++ b/playground-refresh/pages/index.vue @@ -0,0 +1,30 @@ + + + diff --git a/playground-refresh/pages/protected/globally.vue b/playground-refresh/pages/protected/globally.vue new file mode 100644 index 00000000..47d3c469 --- /dev/null +++ b/playground-refresh/pages/protected/globally.vue @@ -0,0 +1,6 @@ + diff --git a/playground-refresh/pages/protected/locally.vue b/playground-refresh/pages/protected/locally.vue new file mode 100644 index 00000000..3af012ec --- /dev/null +++ b/playground-refresh/pages/protected/locally.vue @@ -0,0 +1,14 @@ + + + diff --git a/playground-refresh/pages/signout.vue b/playground-refresh/pages/signout.vue new file mode 100644 index 00000000..2c95468f --- /dev/null +++ b/playground-refresh/pages/signout.vue @@ -0,0 +1,8 @@ + + + diff --git a/playground-refresh/public/favicon.ico b/playground-refresh/public/favicon.ico new file mode 100644 index 00000000..18993ad9 Binary files /dev/null and b/playground-refresh/public/favicon.ico differ diff --git a/playground-refresh/server/api/auth/login.post.ts b/playground-refresh/server/api/auth/login.post.ts new file mode 100644 index 00000000..dbfe1200 --- /dev/null +++ b/playground-refresh/server/api/auth/login.post.ts @@ -0,0 +1,40 @@ +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 new file mode 100644 index 00000000..63938e9f --- /dev/null +++ b/playground-refresh/server/api/auth/logout.post.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..8660a670 --- /dev/null +++ b/playground-refresh/server/api/auth/refresh.post.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..d7417a17 --- /dev/null +++ b/playground-refresh/server/api/auth/user.get.ts @@ -0,0 +1,37 @@ +import { 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/tsconfig.json b/playground-refresh/tsconfig.json new file mode 100644 index 00000000..1dc1eb73 --- /dev/null +++ b/playground-refresh/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./.nuxt/tsconfig.json", + "exclude": ["../docs"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a3e1ab7..037743c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + importers: .: @@ -103,6 +107,31 @@ importers: specifier: ^1.2.0 version: 1.2.0(typescript@5.0.4) + playground-refresh: + dependencies: + jsonwebtoken: + specifier: ^9.0.0 + version: 9.0.0 + zod: + specifier: ^3.21.4 + version: 3.21.4 + devDependencies: + '@types/jsonwebtoken': + specifier: ^9.0.1 + version: 9.0.1 + eslint: + specifier: ^8.37.0 + version: 8.38.0 + nuxt: + specifier: ^3.4.2 + version: 3.4.2(@types/node@18.15.11)(eslint@8.38.0)(rollup@3.20.2)(typescript@5.0.4)(vue-tsc@1.2.0) + typescript: + specifier: ^5.0.3 + version: 5.0.4 + vue-tsc: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.0.4) + packages: /@ampproject/remapping@2.2.1: @@ -1516,7 +1545,7 @@ packages: debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.4.0 + semver: 7.5.0 tsutils: 3.21.0(typescript@5.0.4) typescript: 5.0.4 transitivePeerDependencies: @@ -1537,7 +1566,7 @@ packages: '@typescript-eslint/typescript-estree': 5.58.0(typescript@5.0.4) eslint: 8.38.0 eslint-scope: 5.1.1 - semver: 7.4.0 + semver: 7.5.0 transitivePeerDependencies: - supports-color - typescript @@ -1722,7 +1751,7 @@ packages: '@vue/shared': 3.2.47 estree-walker: 2.0.2 magic-string: 0.25.9 - postcss: 8.4.21 + postcss: 8.4.23 source-map: 0.6.1 /@vue/compiler-ssr@3.2.47: @@ -2296,7 +2325,7 @@ packages: readable-stream: 3.6.2 /concat-map@0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} /consola@3.0.2: resolution: {integrity: sha512-o/Wau2FmZKiQgyp3c3IULgN6J5yc0lwYMnoyiZdEpdGxKGBtt2ACbkulBZ6BUsHy1HlSJqoP4YOyPIJLgRJyKQ==} @@ -2631,7 +2660,7 @@ packages: dev: false /ee-first@1.1.1: - resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} /electron-to-chromium@1.4.364: resolution: {integrity: sha512-v6GxKdF57qfweXSfnne9nw1vS/86G4+UtscEe+3HQF+zhhrjAY4+9A4gstIQO56gyZvVrt9MZwt9aevCz/tohQ==} @@ -2975,7 +3004,7 @@ packages: is-core-module: 2.12.0 minimatch: 3.1.2 resolve: 1.22.3 - semver: 7.4.0 + semver: 7.5.0 dev: true /eslint-plugin-node@11.1.0(eslint@8.38.0): @@ -3021,7 +3050,7 @@ packages: read-pkg-up: 7.0.1 regexp-tree: 0.1.24 safe-regex: 2.1.1 - semver: 7.4.0 + semver: 7.5.0 strip-indent: 3.0.0 dev: true @@ -4390,7 +4419,7 @@ packages: optional: true dependencies: defu: 6.1.2 - esbuild: 0.17.16 + esbuild: 0.17.17 fs-extra: 11.1.1 globby: 13.1.4 jiti: 1.18.2 @@ -5377,14 +5406,6 @@ packages: source-map-js: 1.0.2 dev: false - /postcss@8.4.21: - resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 - /postcss@8.4.23: resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==} engines: {node: ^10 || ^12 || >=14} @@ -5392,7 +5413,6 @@ packages: nanoid: 3.3.6 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /preact-render-to-string@5.2.3(preact@10.11.3): resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} @@ -6402,7 +6422,7 @@ packages: mlly: 1.2.0 pathe: 1.1.0 picocolors: 1.0.0 - vite: 4.2.1(@types/node@18.15.11) + vite: 4.3.1(@types/node@18.15.11) transitivePeerDependencies: - '@types/node' - less @@ -6466,40 +6486,6 @@ packages: vue-tsc: 1.2.0(typescript@5.0.4) dev: true - /vite@4.2.1(@types/node@18.15.11): - resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==} - engines: {node: ^14.18.0 || >=16.0.0} - hasBin: true - peerDependencies: - '@types/node': '>= 14' - less: '*' - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - dependencies: - '@types/node': 18.15.11 - esbuild: 0.17.16 - postcss: 8.4.21 - resolve: 1.22.3 - rollup: 3.20.2 - optionalDependencies: - fsevents: 2.3.2 - dev: true - /vite@4.3.1(@types/node@18.15.11): resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -6611,7 +6597,7 @@ packages: espree: 9.5.1 esquery: 1.5.0 lodash: 4.17.21 - semver: 7.4.0 + semver: 7.5.0 transitivePeerDependencies: - supports-color dev: true diff --git a/src/module.ts b/src/module.ts index 64fe23e3..4809978d 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,10 +1,23 @@ -import { defineNuxtModule, useLogger, createResolver, addTemplate, addPlugin, addServerPlugin, addImports, addRouteMiddleware } from '@nuxt/kit' +import { + defineNuxtModule, + useLogger, + createResolver, + addTemplate, + addPlugin, + addServerPlugin, + addImports, + addRouteMiddleware +} from '@nuxt/kit' import { defu } from 'defu' import { joinURL } from 'ufo' import { genInterface } from 'knitwork' import type { DeepRequired } from 'ts-essentials' import { getOriginAndPathnameFromURL, isProduction } from './runtime/helpers' -import type { ModuleOptions, SupportedAuthProviders, AuthProviders } from './runtime/types' +import type { + ModuleOptions, + SupportedAuthProviders, + AuthProviders +} from './runtime/types' const topLevelDefaults = { isEnabled: true, @@ -19,7 +32,11 @@ const topLevelDefaults = { } } satisfies ModuleOptions -const defaultsByBackend: { [key in SupportedAuthProviders]: DeepRequired> } = { +const defaultsByBackend: { + [key in SupportedAuthProviders]: DeepRequired< + Extract + >; +} = { local: { type: 'local', pages: { @@ -40,6 +57,34 @@ const defaultsByBackend: { [key in SupportedAuthProviders]: DeepRequired({ const logger = useLogger(PACKAGE_NAME) // 0. Assemble all options - const { origin, pathname = '/api/auth' } = getOriginAndPathnameFromURL(userOptions.baseURL ?? '') + const { origin, pathname = '/api/auth' } = getOriginAndPathnameFromURL( + userOptions.baseURL ?? '' + ) const selectedProvider = userOptions.provider?.type ?? 'authjs' const options = { - ...defu( - userOptions, - topLevelDefaults, - { - computed: { - origin, - pathname, - fullBaseUrl: joinURL(origin ?? '', pathname) - } - }), - // We use `as` to infer backend types correclty for runtime-usage (everything is set, although for user everything was optional) - provider: defu(userOptions.provider, defaultsByBackend[selectedProvider]) as DeepRequired + ...defu(userOptions, topLevelDefaults, { + computed: { + origin, + pathname, + fullBaseUrl: joinURL(origin ?? '', pathname) + } + }), + // We use `as` to infer backend types correctly for runtime-usage (everything is set, although for user everything was optional) + provider: defu( + userOptions.provider, + defaultsByBackend[selectedProvider] + ) as DeepRequired } // 1. Check if module should be enabled at all @@ -89,8 +136,13 @@ export default defineNuxtModule({ // 2. Set up runtime configuration if (!isProduction) { - const authjsAddition = selectedProvider === 'authjs' ? ', ensure that `NuxtAuthHandler({ ... })` is there, see https://sidebase.io/nuxt-auth/configuration/nuxt-auth-handler' : '' - logger.info(`Selected provider: ${selectedProvider}. Auth API location is \`${options.computed.fullBaseUrl}\`${authjsAddition}`) + const authjsAddition = + selectedProvider === 'authjs' + ? ', ensure that `NuxtAuthHandler({ ... })` is there, see https://sidebase.io/nuxt-auth/configuration/nuxt-auth-handler' + : '' + logger.info( + `Selected provider: ${selectedProvider}. Auth API location is \`${options.computed.fullBaseUrl}\`${authjsAddition}` + ) } nuxt.options.runtimeConfig = nuxt.options.runtimeConfig || { public: {} } @@ -109,7 +161,9 @@ export default defineNuxtModule({ }, { name: 'useAuthState', - from: resolve(`./runtime/composables/${options.provider.type}/useAuthState`) + from: resolve( + `./runtime/composables/${options.provider.type}/useAuthState` + ) } ]) @@ -118,26 +172,43 @@ export default defineNuxtModule({ nitroConfig.alias = nitroConfig.alias || {} // Inline module runtime in Nitro bundle - nitroConfig.externals = defu(typeof nitroConfig.externals === 'object' ? nitroConfig.externals : {}, { - inline: [resolve('./runtime')] - }) + nitroConfig.externals = defu( + typeof nitroConfig.externals === 'object' ? nitroConfig.externals : {}, + { + inline: [resolve('./runtime')] + } + ) nitroConfig.alias['#auth'] = resolve('./runtime/server/services') }) addTemplate({ filename: 'types/auth.d.ts', - getContents: () => [ - 'declare module \'#auth\' {', - ` const getServerSession: typeof import('${resolve('./runtime/server/services')}').getServerSession`, - ` const getToken: typeof import('${resolve('./runtime/server/services')}').getToken`, - ` const NuxtAuthHandler: typeof import('${resolve('./runtime/server/services')}').NuxtAuthHandler`, - options.provider.type === 'local' ? genInterface('SessionData', (options.provider as any).sessionDataType) : '', - '}' - ].join('\n') + getContents: () => + [ + "declare module '#auth' {", + ` const getServerSession: typeof import('${resolve( + './runtime/server/services' + )}').getServerSession`, + ` const getToken: typeof import('${resolve( + './runtime/server/services' + )}').getToken`, + ` const NuxtAuthHandler: typeof import('${resolve( + './runtime/server/services' + )}').NuxtAuthHandler`, + options.provider.type === 'local' + ? genInterface( + 'SessionData', + (options.provider as any).sessionDataType + ) + : '', + '}' + ].join('\n') }) nuxt.hook('prepare:types', (options) => { - options.references.push({ path: resolve(nuxt.options.buildDir, 'types/auth.d.ts') }) + options.references.push({ + path: resolve(nuxt.options.buildDir, 'types/auth.d.ts') + }) }) // 6. Register middleware for autocomplete in definePageMeta @@ -154,6 +225,11 @@ 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') { + addPlugin(resolve('./runtime/server/plugins/refresh-token.server')) + } + logger.success('`nuxt-auth` setup done') } }) diff --git a/src/runtime/composables/refresh/useAuth.ts b/src/runtime/composables/refresh/useAuth.ts new file mode 100644 index 00000000..2c72f68d --- /dev/null +++ b/src/runtime/composables/refresh/useAuth.ts @@ -0,0 +1,197 @@ +import { Ref } from 'vue' +import { callWithNuxt } from '#app' +import { jsonPointerGet, useTypedBackendConfig } from '../../helpers' +import { useAuth as useLocalAuth } from '../local/useAuth' +import { _fetch } from '../../utils/fetch' +import { getRequestURLWN } from '../../utils/callWithNuxt' +import { 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 config = useTypedBackendConfig(useRuntimeConfig(), 'refresh') + const { path, method } = config.endpoints.signIn + const response = await _fetch>(nuxt, path, { + method, + body: { + ...credentials, + ...(signInOptions ?? {}) + }, + 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 { callbackUrl, redirect = true } = signInOptions ?? {} + if (redirect) { + const urlToNavigateTo = callbackUrl ?? (await getRequestURLWN(nuxt)) + return navigateTo(urlToNavigateTo) + } +} + +const refresh = async () => { + const nuxt = useNuxtApp() + const config = useTypedBackendConfig(useRuntimeConfig(), 'refresh') + const { path, method } = config.endpoints.refresh + + 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: { + refreshToken: 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 } = await callWithNuxt( + nuxt, + useAuthState + ) + + const headers = new Headers({ + [config.token.headerName]: token.value + } as HeadersInit) + data.value = null + rawToken.value = null + rawRefreshToken.value = null + + const { path, method } = config.endpoints.signOut as { + path: string; + method: + | 'get' + | 'head' + | 'patch' + | 'post' + | 'put' + | 'delete' + | 'connect' + | 'options' + | 'trace'; + } + + const res = await _fetch(nuxt, path, { method, headers }) + + const { callbackUrl, redirect = true } = signOutOptions ?? {} + if (redirect) { + await navigateTo(callbackUrl ?? (await getRequestURLWN(nuxt))) + } + + return res +} + +type UseAuthReturn = ReturnType & { + refreshToken: Readonly>; + refresh: () => ReturnType; +}; + +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 new file mode 100644 index 00000000..ffb6c337 --- /dev/null +++ b/src/runtime/composables/refresh/useAuthState.ts @@ -0,0 +1,51 @@ +import { computed, watch, ComputedRef } from 'vue' +import { 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( + 'auth:refresh-token', + { + default: () => null, + maxAge: config.refreshToken.maxAgeInSeconds, + sameSite: 'lax' + } + ) + + 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 03bf1457..638af570 100644 --- a/src/runtime/helpers.ts +++ b/src/runtime/helpers.ts @@ -25,13 +25,18 @@ export const getOriginAndPathnameFromURL = (url: string) => { * 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` or `authjs`) + * @param type Backend type to be enforced (e.g.: `local`,`refresh` or `authjs`) */ -export const useTypedBackendConfig = (runtimeConfig: ReturnType, type: T): Extract, { type: T }> => { - if (runtimeConfig.public.auth.provider.type === type) { - return runtimeConfig.public.auth.provider as Extract, { type: T }> - } - throw new Error('RuntimeError: Type must match at this point') +export const 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') } /** @@ -44,11 +49,18 @@ export const useTypedBackendConfig = (runtimeC * @param obj * @param pointer */ -export const jsonPointerGet = (obj: Record, pointer: string): string | Record => { +export const jsonPointerGet = ( + obj: Record, + pointer: string +): string | Record => { const unescape = (str: string) => str.replace(/~1/g, '/').replace(/~0/g, '~') const parse = (pointer: string) => { - if (pointer === '') { return [] } - if (pointer.charAt(0) !== '/') { throw new Error('Invalid JSON pointer: ' + pointer) } + if (pointer === '') { + return [] + } + if (pointer.charAt(0) !== '/') { + throw new Error('Invalid JSON pointer: ' + pointer) + } return pointer.substring(1).split(/\//).map(unescape) } diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index db926f6e..eb54849e 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -7,10 +7,14 @@ export default defineNuxtPlugin(async (nuxtApp) => { const { data, lastRefreshedAt } = useAuthState() const { getSession } = useAuth() + // use runtimeConfig + const runtimeConfig = useRuntimeConfig().public.auth + // Skip auth if we're prerendering let nitroPrerender = false if (nuxtApp.ssrContext) { - nitroPrerender = getHeader(nuxtApp.ssrContext.event, 'x-nitro-prerender') !== undefined + nitroPrerender = + getHeader(nuxtApp.ssrContext.event, 'x-nitro-prerender') !== undefined } // Only fetch session if it was not yet initialized server-side @@ -19,7 +23,8 @@ export default defineNuxtPlugin(async (nuxtApp) => { } // 2. Setup session maintanence, e.g., auto refreshing or refreshing on foux - const { enableRefreshOnWindowFocus, enableRefreshPeriodically } = useRuntimeConfig().public.auth.session + const { enableRefreshOnWindowFocus, enableRefreshPeriodically } = + runtimeConfig.session // Listen for when the page is visible, if the user switches tabs // and makes our tab visible again, re-fetch the session, but only if @@ -33,17 +38,32 @@ export default defineNuxtPlugin(async (nuxtApp) => { // Refetch interval let refetchIntervalTimer: NodeJS.Timer + // TODO: find more Generic method to start a Timer for the Refresh Token + // Refetch interval for local/refresh schema + let refreshTokenIntervalTimer: NodeJS.Timer + nuxtApp.hook('app:mounted', () => { document.addEventListener('visibilitychange', visibilityHandler, false) if (enableRefreshPeriodically !== false) { - const intervalTime = enableRefreshPeriodically === true ? 1000 : enableRefreshPeriodically + const intervalTime = + enableRefreshPeriodically === true ? 1000 : enableRefreshPeriodically refetchIntervalTimer = setInterval(() => { if (data.value) { getSession() } }, intervalTime) } + + if (runtimeConfig.provider.type === 'refresh') { + const intervalTime = runtimeConfig.provider.token.maxAgeInSeconds * 1000 + const { refresh, refreshToken } = useAuth() + refreshTokenIntervalTimer = setInterval(() => { + if (refreshToken.value) { + refresh() + } + }, intervalTime) + } }) const _unmount = nuxtApp.vueApp.unmount @@ -54,6 +74,11 @@ export default defineNuxtPlugin(async (nuxtApp) => { // Clear refetch interval clearInterval(refetchIntervalTimer) + // Clear refetch interval + if (refreshTokenIntervalTimer) { + clearInterval(refreshTokenIntervalTimer) + } + // Clear session lastRefreshedAt.value = undefined data.value = undefined diff --git a/src/runtime/server/plugins/refresh-token.server.ts b/src/runtime/server/plugins/refresh-token.server.ts new file mode 100644 index 00000000..c7d90ec1 --- /dev/null +++ b/src/runtime/server/plugins/refresh-token.server.ts @@ -0,0 +1,70 @@ +import { _fetch } from '../../utils/fetch' +import { jsonPointerGet, useTypedBackendConfig } from '../../helpers' +import { defineNuxtPlugin, useAuthState, useRuntimeConfig } from '#imports' +export default defineNuxtPlugin({ + name: 'refresh-token-plugin', + enforce: 'pre', + async setup (nuxtApp) { + const { rawToken, rawRefreshToken, refreshToken, token, lastRefreshedAt } = + useAuthState() + + if (refreshToken.value && token.value) { + const config = nuxtApp.$config.public.auth + const configToken = useTypedBackendConfig(useRuntimeConfig(), 'refresh') + + const { path, method } = config.provider.endpoints.refresh + + // include header in case of auth is required to avoid 403 rejection + const headers = new Headers({ + [configToken.token.headerName]: token.value + } as HeadersInit) + + const response = await _fetch>(nuxtApp, path, { + method, + body: { + refreshToken: refreshToken.value + }, + headers + }) + + const extractedToken = jsonPointerGet( + response, + config.provider.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 + } + + // check if refereshTokenOnly + if (!configToken.refreshOnlyToken) { + const extractedRefreshToken = jsonPointerGet( + response, + config.provider.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() + } + } +}) diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 4b986bda..7b88f668 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -12,7 +12,7 @@ interface GlobalMiddlewareOptions { * @example true * @default false */ - isEnabled: boolean + isEnabled: boolean; /** * Whether to enforce authentication if the target-route does not exist. Per default the middleware redirects * to Nuxts' default 404 page instead of forcing a sign-in if the target does not exist. This is to avoid a @@ -24,7 +24,7 @@ interface GlobalMiddlewareOptions { * @example false * @default true */ - allow404WithoutAuth?: boolean + allow404WithoutAuth?: boolean; /** * Whether to automatically set the callback url to the page the user tried to visit when the middleware stopped them. This is useful to disable this when using the credentials provider, as it does not allow a `callbackUrl`. Setting this * to a string-value will result in that being used as the callbackUrl path. Note: You also need to set the global `addDefaultCallbackUrl` setting to `false` if you want to fully disable this. @@ -33,34 +33,43 @@ interface GlobalMiddlewareOptions { * @example /i-caught-you-but-now-you-are-signed-in * @default true */ - addDefaultCallbackUrl?: boolean | string + addDefaultCallbackUrl?: boolean | string; } -type DataObjectPrimitives = 'string' | 'number' | 'boolean' | 'any' | 'undefined' | 'function' | 'null' +type DataObjectPrimitives = + | 'string' + | 'number' + | 'boolean' + | 'any' + | 'undefined' + | 'function' + | 'null'; -type DataObjectArray = `${string}[]` +type DataObjectArray = `${string}[]`; export type SessionDataObject = { - [key: string]: Omit | SessionDataObject + [key: string]: + | Omit + | SessionDataObject; }; /** * Available `nuxt-auth` authentication providers. */ -export type SupportedAuthProviders = 'authjs' | 'local' +export type SupportedAuthProviders = 'authjs' | 'local' | 'refresh'; /** * Configuration for the `local`-provider. */ type ProviderLocal = { /** - * Uses the `local` provider to facilitate autnetication. Currently, two providers exclusive are supported: + * 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`: Username and password provider with support for 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 */ - type: Extract + type: Extract; /** * Endpoints to use for the different methods. `nuxt-auth` will use this and the root-level `baseURL` to create the final request. E.g.: * - `baseURL=/api/auth`, `path=/login` will result in a request to `/api/auth/login` @@ -72,19 +81,19 @@ type ProviderLocal = { * * @default { path: '/login', method: 'post' } */ - signIn?: { path?: string, method?: RouterMethod }, + signIn?: { path?: string; method?: RouterMethod }; /** * What method and path to call to perform the sign-out. Set to false to disable. * * @default { path: '/logout', method: 'post' } */ - signOut?: { path?: string, method?: RouterMethod } | false, + signOut?: { path?: string; method?: RouterMethod } | false; /** * What method and path to call to perform the sign-up. * * @default { path: '/register', method: 'post' } */ - signUp?: { path?: string, method?: RouterMethod }, + signUp?: { path?: string; method?: RouterMethod }; /** * What method and path to call to fetch user / session data from. `nuxt-auth` will send the token received upon sign-in as a header along this request to authenticate. * @@ -93,8 +102,8 @@ type ProviderLocal = { * @default { path: '/session', method: 'get' } * @example { path: '/user', method: 'get' } */ - getSession?: { path?: string, method?: RouterMethod }, - }, + getSession?: { path?: string; method?: RouterMethod }; + }; /** * Pages that `nuxt-auth` needs to know the location off for redirects. */ @@ -104,8 +113,8 @@ type ProviderLocal = { * * @default '/login' */ - login?: string - }, + login?: string; + }; /** * Settings for the authentication-token that `nuxt-auth` receives from the `signIn` endpoint and that can be used to authenticate subsequent requests. */ @@ -121,21 +130,21 @@ type ProviderLocal = { * @default /token Access the `token` 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 */ - signInResponseTokenPointer?: string + signInResponseTokenPointer?: string; /** * Header type to be used in requests. This in combination with `headerName` is used to construct the final authentication-header `nuxt-auth` uses, e.g, for requests via `getSession`. * * @default Bearer * @example Beer */ - type?: string, + type?: string; /** * Header name to be used in requests that need to be authenticated, e.g., to be used in the `getSession` request. * * @default Authorization * @example Auth */ - headerName?: string, + headerName?: 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. * @@ -143,15 +152,15 @@ type ProviderLocal = { * @default 1800 * @example 60 * 60 * 24 */ - maxAgeInSeconds?: number, + 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, - }, + sameSiteAttribute?: boolean | 'lax' | 'strict' | 'none' | undefined; + }; /** * Define an interface for the session data object that `nuxt-auth` expects to receive from the `getSession` endpoint. * @@ -159,8 +168,56 @@ type ProviderLocal = { * @example { id: 'string', name: 'string', email: 'string' } * @advanced_array_example { id: 'string', email: 'string', name: 'string', role: 'admin | guest | account', subscriptions: "{ id: number, status: 'ACTIVE' | 'INACTIVE' }[]" } */ - sessionDataType?: SessionDataObject, -} + sessionDataType?: SessionDataObject; +}; + +/** + * Configuration for the `refresh`-provider an extended version of the local provider. + */ +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 + */ + 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 + * + */ + refreshOnlyToken?: true; + + 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 it's 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; + /** + * 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; + }; +}; /** * Configuration for the `authjs`-provider. @@ -169,11 +226,11 @@ 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`: Username and password provider with support for 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 */ - type: Extract + type: Extract; /** * If set to `true`, `authjs` will use either the `x-forwarded-host` or `host` headers instead of `auth.baseURL`. * @@ -183,21 +240,24 @@ export type ProviderAuthjs = { * You should **try to avoid using advanced options** unless you are very comfortable using them. * @default false */ - trustHost?: boolean + trustHost?: boolean; /** * Select the default-provider to use when `signIn` is called. Setting this here will also effect the global middleware behavior: E.g., when you set it to `github` and the user is unauthorized, they will be directly forwarded to the Github OAuth page instead of seeing the app-login page. * * @example "github" * @default undefined */ - defaultProvider?: undefined | SupportedProviders + defaultProvider?: undefined | SupportedProviders; /** * Whether to add a callbackUrl to sign in requests. Setting this to a string-value will result in that being used as the callbackUrl path. Setting this to `true` will result in the blocked original target path being chosen (if it can be determined). */ - addDefaultCallbackUrl?: boolean | string -} + addDefaultCallbackUrl?: boolean | string; +}; -export type AuthProviders = ProviderAuthjs | ProviderLocal +export type AuthProviders = + | ProviderAuthjs + | ProviderLocal + | ProviderLocalRefresh; /** * Configuration for the application-side session. @@ -214,15 +274,15 @@ type SessionConfig = { * @default false * */ - enableRefreshPeriodically: number | boolean + enableRefreshPeriodically: number | boolean; /** * Whether to refresh the session every time the browser window is refocused. * * @example false * @default true */ - enableRefreshOnWindowFocus: boolean -} + enableRefreshOnWindowFocus: boolean; +}; /** * Configuration for the whole module. @@ -231,7 +291,7 @@ export interface ModuleOptions { /** * Whether the module is enabled at all */ - isEnabled?: boolean + isEnabled?: boolean; /** * Full url at which the app will run combined with the path to authentication. You can set this differently depending on your selected authentication-provider: * - `authjs`: You must set the full URL, with origin and path in production. You can leave this empty in development @@ -262,7 +322,7 @@ export interface ModuleOptions { * @default undefined Default for `authjs` in production, will result in an error * @default /api/auth Default for `local` for both production and development */ - baseURL?: string + baseURL?: string; /** * Configuration of the authentication provider. Different providers are supported: * - auth.js: OAuth focused provider for non-static Nuxt 3 applications @@ -271,11 +331,11 @@ export interface ModuleOptions { * Find more about supported providers here: https://sidebase.io/nuxt-auth/v0.6/getting-started * */ - provider?: AuthProviders + provider?: AuthProviders; /** * Configuration of the application-side session. */ - session?: SessionConfig + session?: SessionConfig; /** * Whether to add a global authentication middleware that protects all pages. Can be either `false` to disable, `true` to enabled * or an object to enable and apply extended configuration. @@ -289,31 +349,31 @@ export interface ModuleOptions { * @example { allow404WithoutAuth: true } * @default false */ - globalAppMiddleware?: GlobalMiddlewareOptions | boolean + globalAppMiddleware?: GlobalMiddlewareOptions | boolean; } // Common useAuthStatus & useAuth return-types -export type SessionLastRefreshedAt = Date | undefined -export type SessionStatus = 'authenticated' | 'unauthenticated' | 'loading' -type WrappedSessionData = Ref +export type SessionLastRefreshedAt = Date | undefined; +export type SessionStatus = 'authenticated' | 'unauthenticated' | 'loading'; +type WrappedSessionData = Ref; export interface CommonUseAuthReturn { - data: Readonly> - lastRefreshedAt: Readonly> - status: ComputedRef - signIn: SignIn - signOut: SignOut - getSession: GetSession + data: Readonly>; + lastRefreshedAt: Readonly>; + status: ComputedRef; + signIn: SignIn; + signOut: SignOut; + getSession: GetSession; } export interface CommonUseAuthStateReturn { - data: WrappedSessionData - loading: Ref - lastRefreshedAt: Ref - status: ComputedRef, + data: WrappedSessionData; + loading: Ref; + lastRefreshedAt: Ref; + status: ComputedRef; _internal: { - baseURL: string - } + baseURL: string; + }; } // Common `useAuth` method-types @@ -324,38 +384,44 @@ export interface SecondarySignInOptions extends Record { * * @default undefined Inferred from the current route */ - callbackUrl?: string + callbackUrl?: string; /** Whether to redirect users after the method succeeded. * * @default true */ - redirect?: boolean + redirect?: boolean; /** Is this callback URL an external one. Setting this to true, allows you to redirect to external urls, however a hard refresh will be done. * * @default false */ - external?: boolean + external?: boolean; } export interface SignOutOptions { - callbackUrl?: string - redirect?: boolean - external?: boolean + callbackUrl?: string; + redirect?: boolean; + external?: boolean; } export type GetSessionOptions = Partial<{ - required?: boolean - callbackUrl?: string - external?: boolean, - onUnauthenticated?: () => void + required?: boolean; + callbackUrl?: string; + external?: boolean; + onUnauthenticated?: () => void; /** Whether to refetch the session even if the token returned by useAuthState is null. * * @default false */ - force?: boolean -}> + force?: boolean; +}>; // TODO: These types could be nicer and more general, or located withing `useAuth` files and more specific -export type SignOutFunc = (options?: SignOutOptions) => Promise -export type GetSessionFunc = (getSessionOptions?: GetSessionOptions) => Promise -export type SignInFunc = (primaryOptions: PrimarySignInOptions, signInOptions?: SecondarySignInOptions, paramsOptions?: Record) => Promise +export type SignOutFunc = (options?: SignOutOptions) => Promise; +export type GetSessionFunc = ( + getSessionOptions?: GetSessionOptions +) => Promise; +export type SignInFunc = ( + primaryOptions: PrimarySignInOptions, + signInOptions?: SecondarySignInOptions, + paramsOptions?: Record +) => Promise; diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index bae05814..2651a539 100644 --- a/src/runtime/utils/fetch.ts +++ b/src/runtime/utils/fetch.ts @@ -2,15 +2,23 @@ import { callWithNuxt } from '#app/nuxt' import { joinPathToApiURL } from './url' import { useNuxtApp } from '#imports' -export const _fetch = async (nuxt: ReturnType, path: string, fetchOptions?: Parameters[1]): Promise => { +export const _fetch = async ( + nuxt: ReturnType, + path: string, + fetchOptions?: Parameters[1] +): Promise => { const joinedPath = await callWithNuxt(nuxt, () => joinPathToApiURL(path)) try { return $fetch(joinedPath, fetchOptions) } catch (error) { // TODO: Adapt this error to be more generic - console.error('Error in `nuxt-auth`-app-side data fetching: Have you added the authentication handler server-endpoint `[...].ts`? Have you added the authentication handler in a non-default location (default is `~/server/api/auth/[...].ts`) and not updated the module-setting `auth.basePath`? Error is:') + console.error( + 'Error in `nuxt-auth`-app-side data fetching: Have you added the authentication handler server-endpoint `[...].ts`? Have you added the authentication handler in a non-default location (default is `~/server/api/auth/[...].ts`) and not updated the module-setting `auth.basePath`? Error is:' + ) console.error(error) - throw new Error('Runtime error, checkout the console logs to debug, open an issue at https://github.com/sidebase/nuxt-auth/issues/new/choose if you continue to have this problem') + throw new Error( + 'Runtime error, checkout the console logs to debug, open an issue at https://github.com/sidebase/nuxt-auth/issues/new/choose if you continue to have this problem' + ) } } diff --git a/tsconfig.json b/tsconfig.json index c4b3934a..2a7b6fa3 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-local/.nuxt/tsconfig.json", + "extends": "./playground-refresh/.nuxt/tsconfig.json", "exclude": ["../docs"], "include": ["src/"] }