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/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..515bc1aa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,4 @@ +# Reporting a security vulnerability + +To report a security issue, please email `sidebase@sidestream.tech` with a description of the issue, the steps you took to create the +issue, affected versions, and if known, mitigations for the issue. Our vulnerability management team will acknowledge receiving your email within 3 working days. This project follows a 90 day disclosure timeline. diff --git a/docs/content/1.getting-started/1.index.md b/docs/content/1.getting-started/1.index.md index 73ecfe94..7e54c277 100644 --- a/docs/content/1.getting-started/1.index.md +++ b/docs/content/1.getting-started/1.index.md @@ -20,9 +20,10 @@ These are the docs for the new v0.6 version of `nuxt-auth` with static Nuxt 3 an - documentation, recipes and example code to get you started :: -`nuxt-auth` employs 2 providers to facilitate the act of authenticating a user: +`nuxt-auth` employs 3 providers to facilitate the act of authenticating a user: ::list{type="success"} - `local`: Username and password authentication. `local` expects the endpoint to return a token that can be used to authenticate subsequent requests +- `refresh`: A extended version of the `local` provider, made for systems that require a token refresh. - `authjs`: A `authjs` (`next-auth`) based provider that supports most OAuth- and Magic-URL sign-ins (think Slack or Notion). This provider also supports username and password based sign-in, but discourages from using it :: @@ -44,26 +45,28 @@ Here's the source-code https://github.com/sidebase/nuxt-auth-example of the exam To pick a provider you will first have to take into consideration the requirements of your use-case. Below is a small table to help you pick: -| | authjs | local | -|----------------------------------------------------------- |-------------------------------------: |------: | -| **Authentication Methods** | | | -| OAuth | ✅ (>50 providers) | ❌ | -| Magic URLs | ✅ | ❌ | -| Credential / Username + Password flow | 🚧 (if possible: use `local` instead) | ✅ | -| | | | -| **Features** | | | -| app `useAuth`-composable to sign-in, sign-out, ... | ✅ | ✅ | -| session-management: auto-refresh, refresh on refocus, ... | ✅ | ✅ | -| static apps ("nuxi generate") | ❌ | ✅ | -| guest mode | ✅ | ✅ | -| app-side middleware | ✅ | ✅ | -| server-side middleware | ✅ | ✅ | -| pre-made login-page | ✅ (impacts bundle-size) | ❌ | -| database-adapters, server-side callback-hooks | ✅ | ❌ | +| | authjs | local | refresh +|----------------------------------------------------------- |-------------------------------------: |------: | ------: +| **Authentication Methods** | | | +| OAuth | ✅ (>50 providers) | ❌ | ❌ +| Magic URLs | ✅ | ❌ | ❌ +| Credentials / Username + Password flow | 🚧 (if possible: use `local` instead) | ✅ | ✅ +| Refresh tokens | ✅ | ❌ | ✅ +| | | | +| **Features** | | | +| app `useAuth`-composable to sign-in, sign-out, ... | ✅ | ✅ | ✅ +| session-management: auto-refresh, refresh on refocus, ... | ✅ | ✅ | ✅ +| static apps ("nuxi generate") | ❌ | ✅ | ✅ +| guest mode | ✅ | ✅ | ✅ +| app-side middleware | ✅ | ✅ | ✅ +| server-side middleware | ✅ | ✅ | ✅ +| pre-made login-page | ✅ (impacts bundle-size) | ❌ | ❌ +| database-adapters, server-side callback-hooks | ✅ | ❌ | ❌ In general one can say that picking: - `authjs` is best suited for plug-and-play OAuth for established oauth-providers or magic-url based sign-ins - `local` is best when you already have a backend that accepts username + password as a login or want to build a static application +- `refresh` if you would need to extend the functionality of the `local` provider, with a refresh token ### `authjs` Remarks diff --git a/docs/content/1.getting-started/2.installation.md b/docs/content/1.getting-started/2.installation.md index 70a28930..3496f27a 100644 --- a/docs/content/1.getting-started/2.installation.md +++ b/docs/content/1.getting-started/2.installation.md @@ -17,27 +17,31 @@ pnpm i -D @sidebase/nuxt-auth ``` :: -::alert{type="info"} -Note that we try our best to keep `nuxt-auth` stable, but it is also a fresh module that is in active development. If you want to be extra sure nothing breaks, you should pin the patch version, e.g., by using `--save-exact` when running the install command. -:: - ## Specifics: `authjs`-Provider If you want to use the `authjs` provider, you have to install `next-auth`. With all package managers except `npm` you must manually install the peer dependency alongside `nuxt-auth`: ::code-group ```bash [yarn] -yarn add next-auth@4.21.1 +yarn add next-auth@4.22.5 ``` ```bash [pnpm] -pnpm i next-auth@4.21.1 +pnpm i next-auth@4.22.5 ``` :: +::alert{type="warning"} +Due to a breaking change in NextAuth, nuxt-auth is only compoatible with NextAuth versions under v4.23.0. We recommend pinning the version to `4.22.5`. See more [here](https://github.com/sidebase/nuxt-auth/issues/514). +:: + +::alert{type="info"} +Note that we try our best to keep `nuxt-auth` stable, but it is also a fresh module that is in active development. If you want to be extra sure nothing breaks, you should pin the patch version, e.g., by using `--save-exact` when running the install command. +:: + You can find all available `next-auth` versions [on npm](https://www.npmjs.com/package/next-auth?activeTab=versions). You do not need to install any other peer-dependencies in order to use `nuxt-auth`. If you are unsure which provider to choose, have a look at the [overview on the getting-started page](/nuxt-auth/v0.6/getting-started#which-provider-should-i-pick). -## Specifics: `local`-Provider +## Specifics: `local`/`refresh`-Provider The `local` provider does not have any specific extra dependencies. However, you will need to make sure that you have a backend somewhere that provides username + password based authentication, [read more about this on the quick-start page](/nuxt-auth/v0.6/getting-started/quick-start). diff --git a/docs/content/1.getting-started/3.quick-start.md b/docs/content/1.getting-started/3.quick-start.md index e2be91f0..a45d9b00 100644 --- a/docs/content/1.getting-started/3.quick-start.md +++ b/docs/content/1.getting-started/3.quick-start.md @@ -22,6 +22,16 @@ export default defineNuxtConfig({ } }) ``` +```ts [refresh] +export default defineNuxtConfig({ + modules: ['@sidebase/nuxt-auth'], + auth: { + provider: { + type: 'refresh' + } + } +}) +``` :: Then continue with the provider-specific steps below. @@ -91,6 +101,56 @@ and return a token that can be used to authenticate future requests in the respo } ``` +### Provider: `refresh` +::alert{type="info"} +The refresh provider is only available in version `0.6.3` and later +:: + +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 +158,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/1.index.md b/docs/content/2.configuration/1.index.md index b8b8343c..ce91ed75 100644 --- a/docs/content/2.configuration/1.index.md +++ b/docs/content/2.configuration/1.index.md @@ -5,7 +5,7 @@ description: "Overview of the configuration options of `nuxt-auth` for Vue / Nux # Overview Use the following places to configure how `nuxt-auth` behaves: -- `local` & `authjs`-provider: The [auth key in `nuxt.config.ts`](/nuxt-auth/v0.6/configuration/nuxt-config). Use it to configure the module itself, e.g., whether global page protection is enabled +- `local`/`refresh` & `authjs`-provider: The [auth key in `nuxt.config.ts`](/nuxt-auth/v0.6/configuration/nuxt-config). Use it to configure the module itself, e.g., whether global page protection is enabled - `authjs`-provider: The [NuxtAuthHandler](/nuxt-auth/v0.6/configuration/nuxt-auth-handler). Use it to configure the `authjs` authentication behavior, e.g., what authentication providers to use. See the detailed possible configuration options on the next pages. diff --git a/docs/content/2.configuration/2.nuxt-config.md b/docs/content/2.configuration/2.nuxt-config.md index 242b26ac..4f7dd152 100644 --- a/docs/content/2.configuration/2.nuxt-config.md +++ b/docs/content/2.configuration/2.nuxt-config.md @@ -138,11 +138,11 @@ type ProviderLocal = { */ signIn?: { path?: string, method?: RouterMethod }, /** - * What method and path to call to perform the sign-out. + * 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 }, + signOut?: { path?: string, method?: RouterMethod } | false, /** * What method and path to call to perform the sign-up. * @@ -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/2.configuration/3.nuxt-auth-handler.md b/docs/content/2.configuration/3.nuxt-auth-handler.md index 6cb152b6..673d6d01 100644 --- a/docs/content/2.configuration/3.nuxt-auth-handler.md +++ b/docs/content/2.configuration/3.nuxt-auth-handler.md @@ -11,9 +11,7 @@ This page is only relevant to you, if you are using the `authjs`-provider. After following the [quick-start setup](/nuxt-auth/v0.6/getting-started/quick-start) and then optionally diving even deeper into the [`nuxt-auth` config inside your `nuxt.config.ts`](/nuxt-auth/v0.6/configuration/nuxt-config) you can begin defining providers and other options inside your `NuxtAuthHandler`. For development, using the quick-start setup will already bring you quite far. For a production deployment, you will have to additionally: -- set the `origin` by: - 1. exporting the `AUTH_ORIGIN`-environment variable _at runtime_ (higher precedence) - 2. setting the `origin`-key inside the `nuxt.config.ts` config _at build-time_ (lower precendence) +- set the `origin` by exporting the `AUTH_ORIGIN`-environment variable _at runtime_ - set a secret inside the `NuxtAuthHandler` config ## Creating the `NuxtAuthHandler` @@ -26,7 +24,7 @@ The filename must be `[...].ts` - this is a so-called "catch-all" route, read mo ## Configuring the `NuxtAuthHandler` -After creating the file, add the `NuxtAuthHandler({ ... })` to it. The `NuxtAuthHandler({ ... })` is used to configure how the authentication itself behaves, what [callbacks should be called](https://next-auth.js.org/configuration/callbacks), what [database adapters should be used](https://next-auth.js.org/adapters/overview) and more: +After creating the file, add the `NuxtAuthHandler({ ... })` to it. The `NuxtAuthHandler({ ... })` is used to configure how the authentication itself behaves, what [callbacks should be called](https://next-auth.js.org/configuration/callbacks), what [database adapters should be used](https://next-auth.js.org/adapters) and more: ::code-group ```ts [Empty NuxtAuthHandler] 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/3.application-side/4.protecting-pages.md b/docs/content/3.application-side/4.protecting-pages.md index 750f40cd..8930b37b 100644 --- a/docs/content/3.application-side/4.protecting-pages.md +++ b/docs/content/3.application-side/4.protecting-pages.md @@ -131,7 +131,8 @@ export default defineNuxtRouteMiddleware((to) => { ``` ```ts [callWithNuxt] // file: ~/middleware/authentication.global.ts -import { callWithNuxt, useNuxtApp } from '#app' +import { useNuxtApp } from '#imports' +import { callWithNuxt } from '#app/nuxt' export default defineNuxtRouteMiddleware((to) => { // It's important to do this as early as possible 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/docs/content/5.recipes/2.strapi.md b/docs/content/5.recipes/2.strapi.md index 81d01fc3..3aec076c 100644 --- a/docs/content/5.recipes/2.strapi.md +++ b/docs/content/5.recipes/2.strapi.md @@ -14,7 +14,7 @@ For a production deployment, you will have to at least set the: 1. Create a `.env` file with the following lines: ```env // Strapi v4 url, out of the box -ORIGIN=http://localhost:3000 +AUTH_ORIGIN=http://localhost:3000 NUXT_SECRET=a-not-so-good-secret STRAPI_BASE_URL=http://localhost:1337/api ``` @@ -28,9 +28,6 @@ export default defineNuxtConfig({ // Default http://localhost:1337/api STRAPI_BASE_URL: process.env.STRAPI_BASE_URL, }, - auth: { - origin: process.env.ORIGIN, - }, }); ``` diff --git a/docs/content/5.recipes/6.laravel-passport.md b/docs/content/5.recipes/6.laravel-passport.md index 41583bc8..72e43d28 100644 --- a/docs/content/5.recipes/6.laravel-passport.md +++ b/docs/content/5.recipes/6.laravel-passport.md @@ -68,8 +68,8 @@ export default defineNuxtConfig({ ```ts // ~/server/api/auth/[...].ts -import {NuxtAuthHandler} from '#auth' -const {passport} = useRuntimeConfig(); //get the values from the runtimeConfig +import { NuxtAuthHandler } from '#auth' +const { passport } = useRuntimeConfig(); //get the values from the runtimeConfig export default NuxtAuthHandler({ //... diff --git a/docs/content/5.recipes/7.vercel-deployment.md b/docs/content/5.recipes/7.vercel-deployment.md index dcb2fcd1..4085b885 100644 --- a/docs/content/5.recipes/7.vercel-deployment.md +++ b/docs/content/5.recipes/7.vercel-deployment.md @@ -20,11 +20,11 @@ In order to run the `nuxt-auth-example` the following environment variables are When deploying `nuxt-auth` to Vercel you cannot set a random string as your nuxt secret. You must use a 32-bit generated secret. You can use [this](https://generate-secret.vercel.app/32) website to generate a custom secret. -### ORIGIN +### AUTH_ORIGIN In order to set the correct origin go into your Vercel project settings and navigate to the `Domains` tab. Once there you will all the assigned domains and their assigned environment (production, dev). -Copy the correct domain for every enviroment and assign the environment variables to match the correct environment. +Copy the correct domain for every enviroment and assign the environment variables `AUTH_ORIGIN` to match the correct environment. ### Github_* diff --git a/docs/content/6.resources/5.errors.md b/docs/content/6.resources/5.errors.md index 15edfd94..5ed65729 100644 --- a/docs/content/6.resources/5.errors.md +++ b/docs/content/6.resources/5.errors.md @@ -27,9 +27,7 @@ Read [the `NuxtAuthHandler` docs for more information and different options you `AUTH_NO_ORIGIN` will appear as a warning message during development and be thrown as an error that stops the application during production. It is safe to ignore the development warning - it is only meant as a heads-up for your later production-deployment. `AUTH_NO_ORIGIN` occurs when the origin of your application was not set. `nuxt-auth` tries to find the origin of your application in the following order: 1. Use the `AUTH_ORIGIN` environment variable if it is set, -2. Use the `auth: { origin: 'https://your-cool-origin.com' }` config-value from the `nuxt.config.ts` if it is set, -3. Development only: Determine the origin automatically from the incoming HTTP request +2. Development only: Determine the origin automatically from the incoming HTTP request The `origin` is important for callbacks that happen to a specific origin for `oauth` flows. Note that in order for (2) to work the `origin` already has to be set at build-time, i.e., when you run `npm run build` or `npm run generate` and it will lead to the `origin` being inside your app-bundle. -Read [the `nuxt.config.ts` docs for more information and different options you can use to set the `origin` at build- and at runtime](/nuxt-auth/v0.6/configuration/nuxt-auth-handler). diff --git a/docs/content/v0.5/2.configuration/3.nuxt-auth-handler.md b/docs/content/v0.5/2.configuration/3.nuxt-auth-handler.md index 0cd446c9..abd7f823 100644 --- a/docs/content/v0.5/2.configuration/3.nuxt-auth-handler.md +++ b/docs/content/v0.5/2.configuration/3.nuxt-auth-handler.md @@ -16,7 +16,7 @@ The filename must be `[...].ts` - this is a so-called "catch-all" route, read mo ## Configuring the `NuxtAuthHandler` -After creating the file, add the `NuxtAuthHandler({ ... })` to it. The `NuxtAuthHandler({ ... })` is used to configure how the authentication itself behaves, what [callbacks should be called](https://next-auth.js.org/configuration/callbacks), what [database adapters should be used](https://next-auth.js.org/adapters/overview) and more: +After creating the file, add the `NuxtAuthHandler({ ... })` to it. The `NuxtAuthHandler({ ... })` is used to configure how the authentication itself behaves, what [callbacks should be called](https://next-auth.js.org/configuration/callbacks), what [database adapters should be used](https://next-auth.js.org/adapters) and more: ::code-group ```ts [Empty NuxtAuthHandler] diff --git a/docs/content/v0.5/3.application-side/4.protecting-pages.md b/docs/content/v0.5/3.application-side/4.protecting-pages.md index fd436567..f8e5128c 100644 --- a/docs/content/v0.5/3.application-side/4.protecting-pages.md +++ b/docs/content/v0.5/3.application-side/4.protecting-pages.md @@ -117,7 +117,8 @@ export default defineNuxtRouteMiddleware((to) => { ``` ```ts [callWithNuxt] // file: ~/middleware/authentication.global.ts -import { callWithNuxt, useNuxtApp } from '#app' +import { useNuxtApp } from '#imports' +import { callWithNuxt } from '#app/nuxt' export default defineNuxtRouteMiddleware((to) => { // It's important to do this as early as possible diff --git a/package.json b/package.json index cf4fa5be..c101dd11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sidebase/nuxt-auth", - "version": "0.6.0", + "version": "0.6.3", "license": "MIT", "type": "module", "exports": { @@ -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 e030649e..0c8296b0 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, @@ -20,7 +33,11 @@ const topLevelDefaults = { } } satisfies ModuleOptions -const defaultsByBackend: { [key in SupportedAuthProviders]: DeepRequired> } = { +const defaultsByBackend: { + [key in SupportedAuthProviders]: DeepRequired< + Extract + >; +} = { local: { type: 'local', pages: { @@ -41,6 +58,34 @@ const defaultsByBackend: { [key in SupportedAuthProviders]: DeepRequired({ meta: { @@ -61,23 +106,25 @@ export default defineNuxtModule({ 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 @@ -90,8 +137,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: {} } @@ -110,7 +162,9 @@ export default defineNuxtModule({ }, { name: 'useAuthState', - from: resolve(`./runtime/composables/${options.provider.type}/useAuthState`) + from: resolve( + `./runtime/composables/${options.provider.type}/useAuthState` + ) } ]) @@ -119,26 +173,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 @@ -155,6 +226,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/authjs/useAuth.ts b/src/runtime/composables/authjs/useAuth.ts index 15981b14..e8a2ae0b 100644 --- a/src/runtime/composables/authjs/useAuth.ts +++ b/src/runtime/composables/authjs/useAuth.ts @@ -2,8 +2,8 @@ import type { AppProvider, BuiltInProviderType } from 'next-auth/providers' import { defu } from 'defu' import { readonly, Ref } from 'vue' import { appendHeader } from 'h3' -import { callWithNuxt } from '#app' -import type { NuxtApp } from '#app' +import { callWithNuxt } from '#app/nuxt' +import type { NuxtApp } from '#app/nuxt' import { determineCallbackUrl } from '../../utils/url' import { makeCWN, joinPathToApiURLWN, navigateToAuthPageWN, getRequestURLWN } from '../../utils/callWithNuxt' import { _fetch } from '../../utils/fetch' diff --git a/src/runtime/composables/commonAuthState.ts b/src/runtime/composables/commonAuthState.ts index f50292f3..a4654a42 100644 --- a/src/runtime/composables/commonAuthState.ts +++ b/src/runtime/composables/commonAuthState.ts @@ -1,9 +1,8 @@ import { computed } from 'vue' import getURL from 'requrl' import { joinURL } from 'ufo' -import { useRuntimeConfig, useRequestEvent } from '#app' import { SessionLastRefreshedAt, SessionStatus } from '../types' -import { useState } from '#imports' +import { useRuntimeConfig, useRequestEvent, useState } from '#imports' export const makeCommonAuthState = () => { const data = useState('auth:data', () => undefined) diff --git a/src/runtime/composables/local/useAuth.ts b/src/runtime/composables/local/useAuth.ts index ecf49286..e9127d3a 100644 --- a/src/runtime/composables/local/useAuth.ts +++ b/src/runtime/composables/local/useAuth.ts @@ -1,5 +1,5 @@ import { readonly, Ref } from 'vue' -import { callWithNuxt } from '#app' +import { callWithNuxt } from '#app/nuxt' import { CommonUseAuthReturn, SignOutFunc, SignInFunc, GetSessionFunc, SecondarySignInOptions } from '../../types' import { _fetch } from '../../utils/fetch' import { jsonPointerGet, useTypedBackendConfig } from '../../helpers' @@ -53,9 +53,13 @@ const signOut: SignOutFunc = async (signOutOptions) => { data.value = null rawToken.value = null - const { path, method } = config.endpoints.signOut + const signOutConfig = config.endpoints.signOut + let res - const res = await _fetch(nuxt, path, { method, headers }) + if (signOutConfig) { + const { path, method } = signOutConfig + res = await _fetch(nuxt, path, { method, headers }) + } const { callbackUrl, redirect = true, external } = signOutOptions ?? {} if (redirect) { diff --git a/src/runtime/composables/local/useAuthState.ts b/src/runtime/composables/local/useAuthState.ts index b12f6b6d..bc634f07 100644 --- a/src/runtime/composables/local/useAuthState.ts +++ b/src/runtime/composables/local/useAuthState.ts @@ -1,5 +1,5 @@ import { computed, watch, ComputedRef } from 'vue' -import { CookieRef } from '#app' +import type { CookieRef } from '#app' import { CommonUseAuthStateReturn } from '../../types' import { makeCommonAuthState } from '../commonAuthState' import { useTypedBackendConfig } from '../../helpers' 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/middleware/auth.ts b/src/runtime/middleware/auth.ts index 04b5dd07..d23d48b8 100644 --- a/src/runtime/middleware/auth.ts +++ b/src/runtime/middleware/auth.ts @@ -1,6 +1,5 @@ -import { navigateTo, defineNuxtRouteMiddleware, useRuntimeConfig } from '#app' import { navigateToAuthPages, determineCallbackUrl } from '../utils/url' -import { useAuth } from '#imports' +import { navigateTo, defineNuxtRouteMiddleware, useRuntimeConfig, useAuth } from '#imports' type MiddlewareMeta = boolean | { /** Whether to only allow unauthenticated users to access this page. @@ -22,7 +21,7 @@ type MiddlewareMeta = boolean | { navigateUnauthenticatedTo?: string } -declare module '#app/../pages/runtime/composables' { +declare module '#app' { interface PageMeta { auth?: MiddlewareMeta } diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 7fcaf5fd..db847ef1 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,17 +1,20 @@ -import { addRouteMiddleware, defineNuxtPlugin, useRuntimeConfig } from '#app' import { getHeader } from 'h3' import authMiddleware from './middleware/auth' -import { useAuth, useAuthState } from '#imports' +import { addRouteMiddleware, defineNuxtPlugin, useRuntimeConfig, useAuth, useAuthState } from '#imports' export default defineNuxtPlugin(async (nuxtApp) => { // 1. Initialize authentication state, potentially fetch current session const { data, lastRefreshedAt, loading } = 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 } // Skip auth if the developer chooses @@ -26,7 +29,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 @@ -40,6 +44,10 @@ 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', () => { if (disableServerSideAuth) { getSession() @@ -48,13 +56,24 @@ export default defineNuxtPlugin(async (nuxtApp) => { 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 @@ -65,6 +84,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 dd8e59d4..496a0312 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. + * 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 }, + 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; /** * Forces your server to send a "loading" status on all requests, prompting the client to fetch on the client. If your website has caching, this prevents the server from caching someone's authentication status. * @default false @@ -267,7 +327,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 @@ -276,11 +336,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. @@ -294,31 +354,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 @@ -329,38 +389,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/callWithNuxt.ts b/src/runtime/utils/callWithNuxt.ts index 1c80c746..97f23b39 100644 --- a/src/runtime/utils/callWithNuxt.ts +++ b/src/runtime/utils/callWithNuxt.ts @@ -1,5 +1,5 @@ -import type { NuxtApp } from '#app' -import { callWithNuxt } from '#app' +import type { NuxtApp } from '#app/nuxt' +import { callWithNuxt } from '#app/nuxt' import { getRequestURL, joinPathToApiURL, navigateToAuthPages } from './url' export const navigateToAuthPageWN = (nuxt: NuxtApp, href: string) => callWithNuxt(nuxt, navigateToAuthPages, [href]) diff --git a/src/runtime/utils/fetch.ts b/src/runtime/utils/fetch.ts index 7b4ac108..2651a539 100644 --- a/src/runtime/utils/fetch.ts +++ b/src/runtime/utils/fetch.ts @@ -1,16 +1,24 @@ -import { callWithNuxt } from '#app' +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/src/runtime/utils/url.ts b/src/runtime/utils/url.ts index 2d2cae44..41ee7660 100644 --- a/src/runtime/utils/url.ts +++ b/src/runtime/utils/url.ts @@ -1,8 +1,7 @@ import { joinURL } from 'ufo' import getURL from 'requrl' import { sendRedirect } from 'h3' -import { useRequestEvent, useNuxtApp, abortNavigation } from '#app' -import { useAuthState, useRuntimeConfig } from '#imports' +import { useRequestEvent, useNuxtApp, abortNavigation, useAuthState, useRuntimeConfig } from '#imports' export const getRequestURL = (includePath = true) => getURL(useRequestEvent()?.node.req, includePath) export const joinPathToApiURL = (path: string) => joinURL(useAuthState()._internal.baseURL, path) 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/"] }