├── .eslintignore ├── .eslintrc.cjs ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── ci.yml │ ├── coana-analysis.yml │ ├── coana-guardrail.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── __tests__ ├── actions.spec.ts ├── auth.spec.ts ├── authkit-callback-route.spec.ts ├── authkit-provider.spec.tsx ├── button.spec.tsx ├── cookie.spec.ts ├── get-authorization-url.spec.ts ├── impersonation.spec.tsx ├── min-max-button.spec.tsx ├── session.spec.ts ├── test-helpers.ts ├── useAccessToken.spec.tsx ├── useTokenClaims.spec.tsx ├── utils.spec.ts └── workos.spec.ts ├── jest.config.ts ├── jest.setup.ts ├── package-lock.json ├── package.json ├── src ├── actions.ts ├── auth.ts ├── authkit-callback-route.ts ├── components │ ├── authkit-provider.tsx │ ├── button.tsx │ ├── impersonation.tsx │ ├── index.ts │ ├── min-max-button.tsx │ ├── useAccessToken.ts │ └── useTokenClaims.ts ├── cookie.ts ├── env-variables.ts ├── get-authorization-url.ts ├── index.ts ├── interfaces.ts ├── middleware.ts ├── session.ts ├── utils.ts └── workos.ts ├── tsconfig.json └── types └── react.d.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.cjs 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'prettier', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:import/recommended', 8 | 'plugin:import/typescript', 9 | ], 10 | settings: { 11 | 'import/resolver': { 12 | typescript: { 13 | extensions: ['.js'], 14 | }, 15 | node: { 16 | extensions: ['.js'], 17 | }, 18 | }, 19 | }, 20 | rules: { 21 | 'import/extensions': [ 22 | 'error', 23 | 'always', 24 | { 25 | ts: 'never', 26 | tsx: 'never', 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See GitHub's docs for more details: 2 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | 4 | # TypeScript Team 5 | * @workos/typescript 6 | 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - authkit-nextjs version [e.g. 0.12.0] 30 | - Next.js version [e.g. 14.2.5] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: {} 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | jobs: 14 | test: 15 | name: Test Node ${{ matrix.node }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | node: [18, 20, 22] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node }} 25 | 26 | - name: Install Dependencies 27 | run: | 28 | npm install 29 | 30 | - name: Prettier 31 | run: | 32 | npm run prettier 33 | 34 | - name: Lint 35 | run: | 36 | npm run lint 37 | 38 | - name: Build 39 | run: | 40 | npm run build 41 | 42 | - name: Test 43 | run: | 44 | npm run test -- --coverage 45 | -------------------------------------------------------------------------------- /.github/workflows/coana-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Coana Vulnerability Analysis 2 | 3 | on: 4 | schedule: 5 | - cron: '0 3 * * *' # every day at 3 AM 6 | workflow_dispatch: 7 | inputs: 8 | tags: 9 | description: 'Manually run vulnerability analysis' 10 | # Required by the return-dispatch action 11 | distinct_id: 12 | 13 | jobs: 14 | coana-vulnerability-analysis: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Run Coana CLI 22 | id: coana-cli 23 | uses: docker://coana/coana:latest 24 | with: 25 | args: | 26 | coana run . \ 27 | --api-key ${{ secrets.COANA_API_KEY }} \ 28 | --repo-url https://github.com/${{github.repository}} 29 | -------------------------------------------------------------------------------- /.github/workflows/coana-guardrail.yml: -------------------------------------------------------------------------------- 1 | name: Coana Guardrail 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | guardrail: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout the ${{github.base_ref}} branch 11 | uses: actions/checkout@v4 12 | with: 13 | ref: ${{github.base_ref}} # checkout the base branch (usually master/main). 14 | 15 | - name: Fetch the PR branch 16 | run: | 17 | git fetch ${{ github.event.pull_request.head.repo.clone_url }} ${{ github.head_ref }}:${{ github.head_ref }} --depth=1 18 | 19 | - name: Get list of changed files relative to the main/master branch 20 | id: changed-files 21 | run: | 22 | echo "all_changed_files=$(git diff --name-only ${{ github.base_ref }} ${{ github.head_ref }} | tr '\n' ' ')" >> $GITHUB_OUTPUT 23 | 24 | - name: Use Node.js 20.x 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 20.x 28 | 29 | - name: Run Coana on the ${{github.base_ref}} branch 30 | run: | 31 | npx @coana-tech/cli run . \ 32 | --guardrail-mode \ 33 | --api-key ${{ secrets.COANA_API_KEY || 'api-key-unavailable' }} \ 34 | -o /tmp/main-branch \ 35 | --changed-files ${{ steps.changed-files.outputs.all_changed_files }} \ 36 | --lightweight-reachability \ 37 | 38 | # Reset file permissions. 39 | # This is necessary because the Coana CLI may add 40 | # new files with root ownership since it's using docker. 41 | # These files will not be deleted by the clean step in checkout 42 | # if the permissions are not reset. 43 | - name: Reset file permissions 44 | run: sudo chown -R $USER:$USER . 45 | 46 | - name: Checkout the current branch 47 | uses: actions/checkout@v4 48 | with: 49 | clean: true 50 | 51 | - name: Run Coana on the current branch 52 | run: | 53 | npx @coana-tech/cli run . \ 54 | --guardrail-mode \ 55 | --api-key ${{ secrets.COANA_API_KEY || 'api-key-unavailable' }} \ 56 | -o /tmp/current-branch \ 57 | --changed-files ${{ steps.changed-files.outputs.all_changed_files }} \ 58 | --lightweight-reachability \ 59 | 60 | - name: Run Report Comparison 61 | run: | 62 | npx @coana-tech/cli compare-reports \ 63 | --api-key ${{ secrets.COANA_API_KEY || 'api-key-unavailable' }} \ 64 | /tmp/main-branch/coana-report.json \ 65 | /tmp/current-branch/coana-report.json 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | # Support manually pushing a new release 5 | workflow_dispatch: {} 6 | # Trigger when a release is published 7 | release: 8 | types: [published] 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | jobs: 15 | test: 16 | name: Publish to NPM 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 18 23 | registry-url: 'https://registry.npmjs.org' 24 | 25 | - name: Install Dependencies 26 | run: | 27 | npm install 28 | 29 | - name: Build project 30 | run: | 31 | npm run build 32 | 33 | - name: Push Release 34 | if: ${{ !github.event.release.prerelease }} 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | run: | 38 | npm publish --tag latest --access=public 39 | 40 | - name: Push Pre-Release 41 | if: ${{ github.event.release.prerelease }} 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | run: | 45 | npm publish --tag next --access=public 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | coverage 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "quoteProps": "consistent", 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 WorkOS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AuthKit Next.js Library 2 | 3 | The AuthKit library for Next.js provides convenient helpers for authentication and session management using WorkOS & AuthKit with Next.js. 4 | 5 | > Note: This library is intended for use with the Next.js App Router. 6 | 7 | ## Installation 8 | 9 | Install the package with: 10 | 11 | ``` 12 | npm i @workos-inc/authkit-nextjs 13 | ``` 14 | 15 | or 16 | 17 | ``` 18 | yarn add @workos-inc/authkit-nextjs 19 | ``` 20 | 21 | ## Video tutorial 22 | 23 | 24 | YouTube tutorial: Next.js App Router Authentication with AuthKit 25 | 26 | 27 | ## Pre-flight 28 | 29 | Make sure the following values are present in your `.env.local` environment variables file. The client ID and API key can be found in the [WorkOS dashboard](https://dashboard.workos.com), and the redirect URI can also be configured there. 30 | 31 | ```sh 32 | WORKOS_CLIENT_ID="client_..." # retrieved from the WorkOS dashboard 33 | WORKOS_API_KEY="sk_test_..." # retrieved from the WorkOS dashboard 34 | WORKOS_COOKIE_PASSWORD="" # generate a secure password here 35 | NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback" # configured in the WorkOS dashboard 36 | ``` 37 | 38 | `WORKOS_COOKIE_PASSWORD` is the private key used to encrypt the session cookie. It has to be at least 32 characters long. You can use the [1Password generator](https://1password.com/password-generator/) or the `openssl` library to generate a strong password via the command line: 39 | 40 | ``` 41 | openssl rand -base64 24 42 | ``` 43 | 44 | To use the `signOut` method, you'll need to set a default Logout URI in your WorkOS dashboard settings under "Redirects". 45 | 46 | ### Optional configuration 47 | 48 | Certain environment variables are optional and can be used to debug or configure cookie settings. 49 | 50 | ```sh 51 | WORKOS_COOKIE_MAX_AGE='600' # maximum age of the cookie in seconds. Defaults to 400 days, the maximum allowed in Chrome 52 | WORKOS_COOKIE_DOMAIN='example.com' 53 | WORKOS_COOKIE_NAME='authkit-cookie' 54 | WORKOS_API_HOSTNAME='api.workos.com' # base WorkOS API URL 55 | WORKOS_API_HTTPS=true # whether to use HTTPS in API calls 56 | WORKOS_API_PORT=3000 # port to use for API calls 57 | 58 | # Only change this if you specifically need cross-origin cookie support. 59 | WORKOS_COOKIE_SAMESITE='lax' # SameSite attribute for cookies: 'lax' (default), 'strict', or 'none'. 60 | ``` 61 | 62 | > [!WARNING] 63 | > Setting `WORKOS_COOKIE_SAMESITE='none'` allows cookies to be sent in cross-origin contexts (like iframes), but reduces protection against CSRF attacks. This setting forces cookies to be secure (HTTPS only) and should only be used when absolutely necessary for your application architecture. 64 | 65 | > [!TIP] >`WORKOS_COOKIE_DOMAIN` can be used to share WorkOS sessions between apps/domains. Note: The `WORKOS_COOKIE_PASSWORD` would need to be the same across apps/domains. Not needed for most use cases. 66 | 67 | ## Setup 68 | 69 | ### Callback route 70 | 71 | WorkOS requires that you have a callback URL to redirect users back to after they've authenticated. In your Next.js app, [expose an API route](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and add the following. 72 | 73 | ```ts 74 | import { handleAuth } from '@workos-inc/authkit-nextjs'; 75 | 76 | export const GET = handleAuth(); 77 | ``` 78 | 79 | Make sure this route matches the `WORKOS_REDIRECT_URI` variable and the configured redirect URI in your WorkOS dashboard. For instance if your redirect URI is `http://localhost:3000/auth/callback` then you'd put the above code in `/app/auth/callback/route.ts`. 80 | 81 | You can also control the pathname the user will be sent to after signing-in by passing a `returnPathname` option to `handleAuth` like so: 82 | 83 | ```ts 84 | export const GET = handleAuth({ returnPathname: '/dashboard' }); 85 | ``` 86 | 87 | If your application needs to persist data upon a successful authentication, like the `oauthTokens` from an upstream provider, you can pass in a `onSuccess` function that will get called after the user has successfully authenticated: 88 | 89 | ```ts 90 | export const GET = handleAuth({ 91 | onSuccess: async ({ user, oauthTokens, authenticationMethod, organizationId }) => { 92 | await saveTokens(oauthTokens); 93 | if (authenticationMethod) { 94 | await saveAuthMethod(user.id, authenticationMethod); 95 | } 96 | }, 97 | }); 98 | ``` 99 | 100 | `handleAuth` can be used with the following options. 101 | 102 | | Option | Default | Description | 103 | | ---------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 104 | | `returnPathname` | `/` | The pathname to redirect the user to after signing in | 105 | | `baseURL` | `undefined` | The base URL to use for the redirect URI instead of the one in the request. Useful if the app is being run in a container like docker where the hostname can be different from the one in the request | 106 | | `onSuccess` | `undefined` | A function that receives successful authentication data and can be used for side-effects like persisting tokens | 107 | | `onError` | `undefined` | A function that can receive the error and the request and handle the error in its own way. | 108 | 109 | #### onSuccess callback data 110 | 111 | The `onSuccess` callback receives the following data: 112 | 113 | | Property | Type | Description | 114 | | ---------------------- | --------------------------- | -------------------------------------------------------------------------------------------------- | 115 | | `user` | `User` | The authenticated user object | 116 | | `accessToken` | `string` | JWT access token | 117 | | `refreshToken` | `string` | Refresh token for session renewal | 118 | | `impersonator` | `Impersonator \| undefined` | Present if user is being impersonated | 119 | | `oauthTokens` | `OauthTokens \| undefined` | OAuth tokens from upstream provider | 120 | | `authenticationMethod` | `string \| undefined` | How the user authenticated (e.g., 'password', 'google-oauth'). Only available during initial login | 121 | | `organizationId` | `string \| undefined` | Organization context of authentication | 122 | 123 | **Note**: `authenticationMethod` is only provided during the initial authentication callback. It will not be available in subsequent requests or session refreshes. 124 | 125 | ### Middleware 126 | 127 | This library relies on [Next.js middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) to provide session management for routes. Put the following in your `middleware.ts` file in the root of your project: 128 | 129 | ```ts 130 | import { authkitMiddleware } from '@workos-inc/authkit-nextjs'; 131 | 132 | export default authkitMiddleware(); 133 | 134 | // Match against pages that require auth 135 | // Leave this out if you want auth on every resource (including images, css etc.) 136 | export const config = { matcher: ['/', '/admin'] }; 137 | ``` 138 | 139 | The middleware can be configured with several options. 140 | 141 | | Option | Default | Description | 142 | | ---------------- | ----------- | ------------------------------------------------------------------------------------------------------ | 143 | | `redirectUri` | `undefined` | Used in cases where you need your redirect URI to be set dynamically (e.g. Vercel preview deployments) | 144 | | `middlewareAuth` | `undefined` | Used to configure middleware auth options. See [middleware auth](#middleware-auth) for more details. | 145 | | `debug` | `false` | Enables debug logs. | 146 | | `signUpPaths` | `[]` | Used to specify paths that should use the 'sign-up' screen hint when redirecting to AuthKit. | 147 | 148 | #### Custom redirect URI 149 | 150 | In cases where you need your redirect URI to be set dynamically (e.g. Vercel preview deployments), use the `redirectUri` option in `authkitMiddleware`: 151 | 152 | ```ts 153 | import { authkitMiddleware } from '@workos-inc/authkit-nextjs'; 154 | 155 | export default authkitMiddleware({ 156 | redirectUri: 'https://foo.example.com/callback', 157 | }); 158 | 159 | // Match against pages that require auth 160 | // Leave this out if you want auth on every resource (including images, css etc.) 161 | export const config = { matcher: ['/', '/admin'] }; 162 | ``` 163 | 164 | Custom redirect URIs will be used over a redirect URI configured in the environment variables. 165 | 166 | ## Usage 167 | 168 | ### Wrap your app in `AuthKitProvider` 169 | 170 | Use `AuthKitProvider` to wrap your app layout, which provides client side auth methods adds protections for auth edge cases. 171 | 172 | ```jsx 173 | import { AuthKitProvider } from '@workos-inc/authkit-nextjs/components'; 174 | 175 | export default function RootLayout({ children }: { children: React.ReactNode }) { 176 | return ( 177 | 178 | 179 | {children} 180 | 181 | 182 | ); 183 | } 184 | ``` 185 | 186 | ### Get the current user in a server component 187 | 188 | For pages where you want to display a signed-in and signed-out view, use `withAuth` to retrieve the user session from WorkOS. 189 | 190 | ```jsx 191 | import Link from 'next/link.js'; 192 | import { getSignInUrl, getSignUpUrl, withAuth, signOut } from '@workos-inc/authkit-nextjs'; 193 | 194 | export default async function HomePage() { 195 | // Retrieves the user from the session or returns `null` if no user is signed in 196 | const { user } = await withAuth(); 197 | 198 | if (!user) { 199 | // Get the URL to redirect the user to AuthKit to sign in 200 | const signInUrl = await getSignInUrl(); 201 | 202 | // Get the URL to redirect the user to AuthKit to sign up 203 | const signUpUrl = await getSignUpUrl(); 204 | 205 | return ( 206 | <> 207 | Log in 208 | Sign Up 209 | 210 | ); 211 | } 212 | 213 | return ( 214 |
{ 216 | 'use server'; 217 | await signOut(); 218 | }} 219 | > 220 |

Welcome back {user?.firstName && `, ${user?.firstName}`}

221 | 222 |
223 | ); 224 | } 225 | ``` 226 | 227 | ### Get the current user in a client component 228 | 229 | For client components, use the `useAuth` hook to get the current user session. 230 | 231 | ```jsx 232 | // Note the updated import path 233 | import { useAuth } from '@workos-inc/authkit-nextjs/components'; 234 | 235 | export default function MyComponent() { 236 | // Retrieves the user from the session or returns `null` if no user is signed in 237 | const { user, loading } = useAuth(); 238 | 239 | if (loading) { 240 | return
Loading...
; 241 | } 242 | 243 | return
{user?.firstName}
; 244 | } 245 | ``` 246 | 247 | ### Requiring auth 248 | 249 | For pages where a signed-in user is mandatory, you can use the `ensureSignedIn` option: 250 | 251 | ```jsx 252 | // Server component 253 | const { user } = await withAuth({ ensureSignedIn: true }); 254 | 255 | // Client component 256 | const { user, loading } = useAuth({ ensureSignedIn: true }); 257 | ``` 258 | 259 | Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to access the page without being authenticated. 260 | 261 | ### Refreshing the session 262 | 263 | Use the `refreshSession` method in a server action or route handler to fetch the latest session details, including any changes to the user's roles or permissions. 264 | 265 | The `organizationId` parameter can be passed to `refreshSession` in order to switch the session to a different organization. If the current session is not authorized for the next organization, an appropriate [authentication error](https://workos.com/docs/reference/user-management/authentication-errors) will be returned. 266 | 267 | In client components, you can refresh the session with the `refreshAuth` hook. 268 | 269 | ```tsx 270 | 'use client'; 271 | 272 | import { useAuth } from '@workos-inc/authkit-nextjs/components'; 273 | import React, { useEffect } from 'react'; 274 | 275 | export function SwitchOrganizationButton() { 276 | const { user, organizationId, loading, refreshAuth } = useAuth(); 277 | 278 | useEffect(() => { 279 | // This will log out the new organizationId after refreshing the session 280 | console.log('organizationId', organizationId); 281 | }, [organizationId]); 282 | 283 | if (loading) { 284 | return
Loading...
; 285 | } 286 | 287 | const handleRefreshSession = async () => { 288 | const result = await refreshAuth({ 289 | // Provide the organizationId to switch to 290 | organizationId: 'org_123', 291 | }); 292 | if (result?.error) { 293 | console.log('Error refreshing session:', result.error); 294 | } 295 | }; 296 | 297 | if (user) { 298 | return ; 299 | } else { 300 | return
Not signed in
; 301 | } 302 | } 303 | ``` 304 | 305 | ### Access Token Management 306 | 307 | #### useAccessToken Hook 308 | 309 | This library provides a `useAccessToken` hook for client-side access token management with automatic refresh functionality. 310 | 311 | ##### Features 312 | 313 | - Automatic token refresh before expiration 314 | - Manual refresh capability 315 | - Loading and error states 316 | - Synchronized with the main authentication session 317 | - Race condition prevention 318 | 319 | ##### When to Use 320 | 321 | Use this hook when you need direct access to the JWT token for: 322 | 323 | - Making authenticated API calls 324 | - Setting up external auth-dependent libraries 325 | - Implementing custom authentication logic 326 | 327 | ##### Basic Usage 328 | 329 | ```jsx 330 | function ApiClient() { 331 | const { accessToken, loading, error, refresh } = useAccessToken(); 332 | 333 | if (loading) return
Loading...
; 334 | if (error) return
Error: {error.message}
; 335 | if (!accessToken) return
Not authenticated
; 336 | 337 | return ( 338 |
339 |

Token available: {accessToken.substring(0, 10)}...

340 | 341 |
342 | ); 343 | } 344 | ``` 345 | 346 | ##### API Reference 347 | 348 | | Property | Type | Description | 349 | | ------------- | ------------------------------------ | --------------------------------------------- | 350 | | `accessToken` | `string \| undefined` | The current access token | 351 | | `loading` | `boolean` | True when token is being fetched or refreshed | 352 | | `error` | `Error \| null` | Error during token fetch/refresh, or null | 353 | | `refresh` | `() => Promise` | Manually refresh the token | 354 | 355 | ##### Integration with useAuth 356 | 357 | The `useAccessToken` hook automatically synchronizes with the main authentication session. When you call `refreshAuth()` from `useAuth`, the access token will update accordingly. Similarly, using the `refresh()` method from `useAccessToken` will update the entire authentication session. 358 | 359 | ##### Security Considerations 360 | 361 | JWT tokens are sensitive credentials and should be handled carefully: 362 | 363 | - Only use the token where necessary 364 | - Don't store tokens in localStorage or sessionStorage 365 | - Be cautious about exposing tokens in your application state 366 | 367 | ### Session Refresh Callbacks 368 | 369 | When using the `authkit` function directly, you can provide callbacks to be notified when a session is refreshed: 370 | 371 | ```typescript 372 | const { session, headers } = await authkit(request, { 373 | onSessionRefreshSuccess: async ({ accessToken, user, impersonator }) => { 374 | // Log successful refresh 375 | console.log(`Session refreshed for ${user.email}.`); 376 | }, 377 | onSessionRefreshError: async ({ error, request }) => { 378 | // Log refresh failure 379 | console.error('Session refresh failed:', error); 380 | // Notify monitoring system 381 | await notifyMonitoring('session_refresh_failed', { 382 | url: request.url, 383 | error: error.message, 384 | }); 385 | }, 386 | }); 387 | ``` 388 | 389 | These callbacks provide a way to perform side effects when sessions are refreshed in the middleware. Common use cases include: 390 | 391 | - Logging authentication events 392 | - Updating last activity timestamps 393 | - Triggering organization-specific data prefetching 394 | - Recording failed refresh attempts 395 | 396 | ### Middleware auth 397 | 398 | The default behavior of this library is to request authentication via the `withAuth` method on a per-page basis. There are some use cases where you don't want to call `withAuth` (e.g. you don't need user data for your page) or if you'd prefer a "secure by default" approach where every route defined in your middleware matcher is protected unless specified otherwise. In those cases you can opt-in to use middleware auth instead: 399 | 400 | ```ts 401 | import { authkitMiddleware } from '@workos-inc/authkit-nextjs'; 402 | 403 | export default authkitMiddleware({ 404 | middlewareAuth: { 405 | enabled: true, 406 | unauthenticatedPaths: ['/', '/about'], 407 | }, 408 | }); 409 | 410 | // Match against pages that require auth 411 | // Leave this out if you want auth on every resource (including images, css etc.) 412 | export const config = { matcher: ['/', '/admin/:path*', '/about'] }; 413 | ``` 414 | 415 | In the above example the `/admin` page will require a user to be signed in, whereas `/` and `/about` can be accessed without signing in. 416 | 417 | `unauthenticatedPaths` uses the same glob logic as the [Next.js matcher](https://nextjs.org/docs/pages/building-your-application/routing/middleware#matcher). 418 | 419 | ### Composing middleware 420 | 421 | If you don't want to use `authkitMiddleware` and instead want to compose your own middleware, you can use the `authkit` method. In this mode you are responsible to handling what to do when there's no session on a protected route. 422 | 423 | ```ts 424 | export default async function middleware(request: NextRequest) { 425 | // Perform logic before or after AuthKit 426 | 427 | // Auth object contains the session, response headers and an auhorization URL in the case that the session isn't valid 428 | // This method will automatically handle setting the cookie and refreshing the session 429 | const { session, headers, authorizationUrl } = await authkit(request, { 430 | debug: true, 431 | }); 432 | 433 | // Control of what to do when there's no session on a protected route is left to the developer 434 | if (request.url.includes('/account') && !session.user) { 435 | console.log('No session on protected path'); 436 | return NextResponse.redirect(authorizationUrl); 437 | 438 | // Alternatively you could redirect to your own login page, for example if you want to use your own UI instead of hosted AuthKit 439 | return NextResponse.redirect('/login'); 440 | } 441 | 442 | // Headers from the authkit response need to be included in every non-redirect response to ensure that `withAuth` works as expected 443 | return NextResponse.next({ 444 | headers: headers, 445 | }); 446 | } 447 | 448 | // Match against the pages 449 | export const config = { matcher: ['/', '/account/:path*'] }; 450 | ``` 451 | 452 | ### Signing out 453 | 454 | Use the `signOut` method to sign out the current logged in user and redirect to your app's default Logout URI. The Logout URI is set in your WorkOS dashboard settings under "Redirect". 455 | 456 | To use a non-default Logout URI, you can use the `returnTo` parameter. 457 | 458 | ```tsx 459 | await signOut({ returnTo: 'https://your-app.com/signed-out' }); 460 | ``` 461 | 462 | ### Visualizing an impersonation 463 | 464 | Render the `Impersonation` component in your app so that it is clear when someone is [impersonating a user](https://workos.com/docs/user-management/impersonation). 465 | The component will display a frame with some information about the impersonated user, as well as a button to stop impersonating. 466 | 467 | ```jsx 468 | import { Impersonation, AuthKitProvider } from '@workos-inc/authkit-nextjs/components'; 469 | 470 | export default function App() { 471 | return ( 472 |
473 | 474 | 475 | {/* Your app content */} 476 | 477 |
478 | ); 479 | } 480 | ``` 481 | 482 | ### Get the access token 483 | 484 | Sometimes it is useful to obtain the access token directly, for instance to make API requests to another service. 485 | 486 | ```jsx 487 | import { withAuth } from '@workos-inc/authkit-nextjs'; 488 | 489 | export default async function HomePage() { 490 | const { accessToken } = await withAuth(); 491 | 492 | if (!accessToken) { 493 | return
Not signed in
; 494 | } 495 | 496 | const serviceData = await fetch('/api/path', { 497 | headers: { 498 | Authorization: `Bearer ${accessToken}`, 499 | }, 500 | }); 501 | 502 | return
{serviceData}
; 503 | } 504 | ``` 505 | 506 | ### Sign up paths 507 | 508 | The `signUpPaths` option can be passed to `authkitMiddleware` to specify paths that should use the 'sign-up' screen hint when redirecting to AuthKit. This is useful for cases where you want a path that mandates authentication to be treated as a sign up page. 509 | 510 | ```ts 511 | import { authkitMiddleware } from '@workos-inc/authkit-nextjs'; 512 | 513 | export default authkitMiddleware({ 514 | signUpPaths: ['/account/sign-up', '/dashboard/:path*'], 515 | }); 516 | ``` 517 | 518 | ### Advanced: Direct access to the WorkOS client 519 | 520 | For advanced use cases or functionality not covered by the helper methods, you can access the underlying WorkOS client directly: 521 | 522 | ```typescript 523 | import { getWorkOS } from '@workos-inc/authkit-nextjs'; 524 | 525 | // Get the configured WorkOS client instance 526 | const workos = getWorkOS(); 527 | 528 | // Use any WorkOS SDK method 529 | const organizations = await workos.organizations.listOrganizations({ 530 | limit: 10, 531 | }); 532 | ``` 533 | 534 | ### Advanced: Custom authentication flows 535 | 536 | While the standard authentication flow handles session management automatically, some use cases require manually creating and storing a session. This is useful for custom authentication flows like email verification or token exchange. 537 | 538 | For these scenarios, you can use the `saveSession` function: 539 | 540 | ```typescript 541 | import { saveSession } from '@workos-inc/authkit-nextjs'; 542 | import { getWorkOS } from '@workos-inc/authkit-nextjs'; 543 | 544 | // Example: Email verification flow 545 | async function handleEmailVerification(req) { 546 | const { code } = await req.json(); 547 | 548 | // Authenticate with the WorkOS API directly 549 | const authResponse = await getWorkOS().userManagement.authenticateWithEmailVerification({ 550 | clientId: process.env.WORKOS_CLIENT_ID, 551 | code, 552 | }); 553 | 554 | // Save the session data to a cookie 555 | await saveSession( 556 | { 557 | accessToken: authResponse.accessToken, 558 | refreshToken: authResponse.refreshToken, 559 | user: authResponse.user, 560 | impersonator: authResponse.impersonator, 561 | }, 562 | req, 563 | ); 564 | 565 | return Response.redirect('/dashboard'); 566 | } 567 | ``` 568 | 569 | > [!NOTE] 570 | > This is an advanced API intended for specific integration scenarios, such as those users using self-hosted AuthKit. If you're using hosted AuthKit you should not need this. 571 | 572 | The `saveSession` function accepts either a `NextRequest` object or a URL string as its second parameter. 573 | 574 | ```typescript 575 | // With NextRequest 576 | await saveSession(session, req); 577 | 578 | // With URL string 579 | await saveSession(session, 'https://example.com/callback'); 580 | ``` 581 | 582 | ### Debugging 583 | 584 | To enable debug logs, initialize the middleware with the debug flag enabled. 585 | 586 | ```js 587 | import { authkitMiddleware } from '@workos-inc/authkit-nextjs'; 588 | 589 | export default authkitMiddleware({ debug: true }); 590 | ``` 591 | 592 | ### Troubleshooting 593 | 594 | #### NEXT_REDIRECT error when using try/catch blocks 595 | 596 | Wrapping a `withAuth({ ensureSignedIn: true })` call in a try/catch block will cause a `NEXT_REDIRECT` error. This is because `withAuth` will attempt to redirect the user to AuthKit if no session is detected and redirects in Next must be [called outside a try/catch](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting). 597 | 598 | #### Module build failed: UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins (Unhandled scheme). 599 | 600 | You may encounter this error if you attempt to import server side code from authkit-nextjs into a client component. Likely you are using `withAuth` in a client component instead of the `useAuth` hook. Either move the code to a server component or use the `useAuth` hook. 601 | -------------------------------------------------------------------------------- /__tests__/actions.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | checkSessionAction, 3 | handleSignOutAction, 4 | getOrganizationAction, 5 | getAuthAction, 6 | refreshAuthAction, 7 | switchToOrganizationAction, 8 | getAccessTokenAction, 9 | refreshAccessTokenAction, 10 | } from '../src/actions.js'; 11 | import { signOut, switchToOrganization } from '../src/auth.js'; 12 | import { getWorkOS } from '../src/workos.js'; 13 | import { withAuth, refreshSession } from '../src/session.js'; 14 | 15 | jest.mock('../src/auth.js', () => ({ 16 | signOut: jest.fn().mockResolvedValue(true), 17 | switchToOrganization: jest.fn().mockResolvedValue({ organizationId: 'org_123' }), 18 | })); 19 | 20 | const fakeWorkosInstance = { 21 | organizations: { 22 | getOrganization: jest.fn().mockResolvedValue({ id: 'org_123', name: 'Test Org' }), 23 | }, 24 | }; 25 | jest.mock('../src/workos.js', () => ({ 26 | getWorkOS: jest.fn(() => fakeWorkosInstance), 27 | })); 28 | 29 | jest.mock('../src/session.js', () => ({ 30 | withAuth: jest.fn().mockResolvedValue({ user: 'testUser', accessToken: 'access_token' }), 31 | refreshSession: jest.fn().mockResolvedValue({ session: 'newSession', accessToken: 'refreshed_token' }), 32 | })); 33 | 34 | describe('actions', () => { 35 | const workos = getWorkOS(); 36 | describe('checkSessionAction', () => { 37 | it('should return true for authenticated users', async () => { 38 | const result = await checkSessionAction(); 39 | expect(result).toBe(true); 40 | }); 41 | }); 42 | 43 | describe('handleSignOutAction', () => { 44 | it('should call signOut', async () => { 45 | await handleSignOutAction(); 46 | expect(signOut).toHaveBeenCalled(); 47 | }); 48 | }); 49 | 50 | describe('getOrganizationAction', () => { 51 | it('should return organization details', async () => { 52 | const organizationId = 'org_123'; 53 | const result = await getOrganizationAction(organizationId); 54 | expect(workos.organizations.getOrganization).toHaveBeenCalledWith(organizationId); 55 | expect(result).toEqual({ id: 'org_123', name: 'Test Org' }); 56 | }); 57 | }); 58 | 59 | describe('getAuthAction', () => { 60 | it('should return auth details', async () => { 61 | const result = await getAuthAction(); 62 | expect(withAuth).toHaveBeenCalled(); 63 | expect(result).toEqual({ user: 'testUser' }); 64 | }); 65 | }); 66 | 67 | describe('refreshAuthAction', () => { 68 | it('should refresh session', async () => { 69 | const params = { ensureSignedIn: true, organizationId: 'org_123' }; 70 | const result = await refreshAuthAction(params); 71 | expect(refreshSession).toHaveBeenCalledWith(params); 72 | expect(result).toEqual({ session: 'newSession' }); 73 | }); 74 | }); 75 | 76 | describe('switchToOrganizationAction', () => { 77 | it('should switch organizations', async () => { 78 | const options = { returnTo: '/test' }; 79 | const result = await switchToOrganizationAction('org_123', options); 80 | expect(switchToOrganization).toHaveBeenCalledWith('org_123', options); 81 | expect(result).toEqual({ organizationId: 'org_123' }); 82 | }); 83 | }); 84 | 85 | describe('getAccessTokenAction', () => { 86 | it('should return access token', async () => { 87 | const result = await getAccessTokenAction(); 88 | expect(withAuth).toHaveBeenCalled(); 89 | expect(result).toEqual('access_token'); 90 | }); 91 | }); 92 | 93 | describe('refreshAccessTokenAction', () => { 94 | it('should refresh access token', async () => { 95 | const result = await refreshAccessTokenAction(); 96 | expect(refreshSession).toHaveBeenCalled(); 97 | expect(result).toEqual('refreshed_token'); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /__tests__/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | 3 | import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from '../src/auth.js'; 4 | import * as session from '../src/session.js'; 5 | import * as cache from 'next/cache.js'; 6 | import * as workosModule from '../src/workos.js'; 7 | 8 | // These are mocked in jest.setup.ts 9 | import { cookies, headers } from 'next/headers.js'; 10 | import { redirect } from 'next/navigation.js'; 11 | import { generateSession, generateTestToken } from './test-helpers.js'; 12 | import { sealData } from 'iron-session'; 13 | import { getWorkOS } from '../src/workos.js'; 14 | 15 | const workos = getWorkOS(); 16 | 17 | jest.mock('next/cache.js', () => { 18 | const actual = jest.requireActual('next/cache.js'); 19 | return { 20 | ...actual, 21 | revalidateTag: jest.fn(), 22 | revalidatePath: jest.fn(), 23 | }; 24 | }); 25 | 26 | // Create a fake WorkOS instance that will be used only in the "on error" tests 27 | const fakeWorkosInstance = { 28 | userManagement: { 29 | authenticateWithRefreshToken: jest.fn(), 30 | getAuthorizationUrl: jest.fn(), 31 | getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'), 32 | getLogoutUrl: jest.fn(), 33 | }, 34 | }; 35 | 36 | const revalidatePath = jest.mocked(cache.revalidatePath); 37 | const revalidateTag = jest.mocked(cache.revalidateTag); 38 | // We'll only use these in the "on error" tests 39 | const authenticateWithRefreshToken = fakeWorkosInstance.userManagement.authenticateWithRefreshToken; 40 | const getAuthorizationUrl = fakeWorkosInstance.userManagement.getAuthorizationUrl; 41 | 42 | jest.mock('../src/session', () => { 43 | const actual = jest.requireActual('../src/session'); 44 | 45 | return { 46 | ...actual, 47 | refreshSession: jest.fn(actual.refreshSession), 48 | }; 49 | }); 50 | 51 | describe('auth.ts', () => { 52 | beforeEach(async () => { 53 | // Clear all mocks between tests 54 | jest.clearAllMocks(); 55 | 56 | // Reset the cookie store 57 | const nextCookies = await cookies(); 58 | // @ts-expect-error - _reset is part of the mock 59 | nextCookies._reset(); 60 | 61 | const nextHeaders = await headers(); 62 | // @ts-expect-error - _reset is part of the mock 63 | nextHeaders._reset(); 64 | }); 65 | 66 | describe('getSignInUrl', () => { 67 | it('should return a valid URL', async () => { 68 | const url = await getSignInUrl(); 69 | expect(url).toBeDefined(); 70 | expect(() => new URL(url)).not.toThrow(); 71 | }); 72 | 73 | it('should use the organizationId if provided', async () => { 74 | const url = await getSignInUrl({ organizationId: 'org_123' }); 75 | expect(url).toContain('organization_id=org_123'); 76 | expect(url).toBeDefined(); 77 | expect(() => new URL(url)).not.toThrow(); 78 | }); 79 | }); 80 | 81 | describe('getSignUpUrl', () => { 82 | it('should return a valid URL', async () => { 83 | const url = await getSignUpUrl(); 84 | expect(url).toBeDefined(); 85 | expect(() => new URL(url)).not.toThrow(); 86 | }); 87 | }); 88 | 89 | describe('switchToOrganization', () => { 90 | it('should refresh the session with the new organizationId', async () => { 91 | const nextHeaders = await headers(); 92 | nextHeaders.set('x-url', 'http://localhost/test'); 93 | await switchToOrganization('org_123'); 94 | expect(revalidatePath).toHaveBeenCalledWith('http://localhost/test'); 95 | }); 96 | 97 | it('should revalidate the path and refresh the session with the new organizationId', async () => { 98 | const nextHeaders = await headers(); 99 | nextHeaders.set('x-url', 'http://localhost/test'); 100 | await switchToOrganization('org_123', { returnTo: '/test' }); 101 | expect(session.refreshSession).toHaveBeenCalledTimes(1); 102 | expect(session.refreshSession).toHaveBeenCalledWith({ organizationId: 'org_123', ensureSignedIn: true }); 103 | expect(revalidatePath).toHaveBeenCalledWith('/test'); 104 | }); 105 | 106 | it('should revalidate the provided tags and refresh the session with the new organizationId', async () => { 107 | const nextHeaders = await headers(); 108 | nextHeaders.set('x-url', 'http://localhost/test'); 109 | await switchToOrganization('org_123', { revalidationStrategy: 'tag', revalidationTags: ['tag1', 'tag2'] }); 110 | expect(revalidateTag).toHaveBeenCalledTimes(2); 111 | }); 112 | 113 | describe('on error', () => { 114 | beforeEach(async () => { 115 | const nextHeaders = await headers(); 116 | nextHeaders.set('x-url', 'http://localhost/test'); 117 | await generateSession(); 118 | 119 | // Create a WorkOS-like object that matches what our tests need 120 | const mockWorkOS = { 121 | userManagement: fakeWorkosInstance.userManagement, 122 | // Add minimal properties to satisfy TypeScript 123 | createHttpClient: jest.fn(), 124 | createWebhookClient: jest.fn(), 125 | createActionsClient: jest.fn(), 126 | createIronSessionProvider: jest.fn(), 127 | apiKey: 'test', 128 | clientId: 'test', 129 | host: 'test', 130 | port: 443, 131 | protocol: 'https', 132 | headers: {}, 133 | version: '0.0.0', 134 | }; 135 | 136 | // Apply the mock for these tests only 137 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 138 | jest.spyOn(workosModule, 'getWorkOS').mockImplementation(() => mockWorkOS as any); 139 | }); 140 | 141 | afterEach(() => { 142 | // Restore all mocks after each test 143 | jest.restoreAllMocks(); 144 | }); 145 | 146 | it('should redirect to sign in when error is "sso_required"', async () => { 147 | authenticateWithRefreshToken.mockImplementation(() => { 148 | return Promise.reject({ 149 | status: 500, 150 | requestID: 'sso_required', 151 | error: 'sso_required', 152 | errorDescription: 'User must authenticate using one of the matching connections.', 153 | }); 154 | }); 155 | 156 | await switchToOrganization('org_123'); 157 | expect(getAuthorizationUrl).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' })); 158 | expect(redirect).toHaveBeenCalledTimes(1); 159 | }); 160 | 161 | it('should redirect to sign in when error is "mfa_enrollment"', async () => { 162 | authenticateWithRefreshToken.mockImplementation(() => { 163 | return Promise.reject({ 164 | status: 500, 165 | requestID: 'mfa_enrollment', 166 | error: 'mfa_enrollment', 167 | errorDescription: 'User must authenticate using one of the matching connections.', 168 | }); 169 | }); 170 | 171 | await switchToOrganization('org_123'); 172 | expect(getAuthorizationUrl).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' })); 173 | expect(redirect).toHaveBeenCalledTimes(1); 174 | }); 175 | 176 | it('should redirect to the authkit_redirect_url when provided', async () => { 177 | authenticateWithRefreshToken.mockImplementation(() => { 178 | return Promise.reject({ 179 | rawData: { 180 | authkit_redirect_url: 'http://localhost/test', 181 | }, 182 | }); 183 | }); 184 | await switchToOrganization('org_123'); 185 | expect(redirect).toHaveBeenCalledWith('http://localhost/test'); 186 | }); 187 | 188 | it('throws other errors', async () => { 189 | authenticateWithRefreshToken.mockImplementation(() => { 190 | return Promise.reject(new Error('Fail')); 191 | }); 192 | await expect(switchToOrganization('org_123')).rejects.toThrow('Fail'); 193 | }); 194 | }); 195 | }); 196 | 197 | describe('signOut', () => { 198 | it('should delete the cookie and redirect', async () => { 199 | const nextCookies = await cookies(); 200 | const nextHeaders = await headers(); 201 | 202 | nextHeaders.set('x-workos-middleware', 'true'); 203 | nextCookies.set('wos-session', 'foo'); 204 | 205 | await signOut(); 206 | 207 | const sessionCookie = nextCookies.get('wos-session'); 208 | 209 | expect(sessionCookie).toBeUndefined(); 210 | expect(redirect).toHaveBeenCalledTimes(1); 211 | expect(redirect).toHaveBeenCalledWith('/'); 212 | }); 213 | 214 | it('should delete the cookie with a specific domain', async () => { 215 | const nextCookies = await cookies(); 216 | const nextHeaders = await headers(); 217 | 218 | nextHeaders.set('x-workos-middleware', 'true'); 219 | nextCookies.set('wos-session', 'foo', { domain: 'example.com' }); 220 | 221 | await signOut(); 222 | 223 | const sessionCookie = nextCookies.get('wos-session'); 224 | expect(sessionCookie).toBeUndefined(); 225 | }); 226 | 227 | describe('when given a `returnTo` parameter', () => { 228 | it('passes the `returnTo` through to the `getLogoutUrl` call', async () => { 229 | jest 230 | .spyOn(workos.userManagement, 'getLogoutUrl') 231 | .mockReturnValue('https://user-management-logout.com/signed-out'); 232 | const mockSession = { 233 | accessToken: await generateTestToken(), 234 | sessionId: 'session_123', 235 | } as const; 236 | 237 | const nextHeaders = await headers(); 238 | nextHeaders.set( 239 | 'x-workos-session', 240 | await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), 241 | ); 242 | 243 | nextHeaders.set('x-workos-middleware', 'true'); 244 | 245 | await signOut({ returnTo: 'https://example.com/signed-out' }); 246 | 247 | expect(redirect).toHaveBeenCalledTimes(1); 248 | expect(redirect).toHaveBeenCalledWith('https://user-management-logout.com/signed-out'); 249 | expect(workos.userManagement.getLogoutUrl).toHaveBeenCalledWith( 250 | expect.objectContaining({ 251 | returnTo: 'https://example.com/signed-out', 252 | }), 253 | ); 254 | }); 255 | 256 | describe('when there is no session', () => { 257 | it('returns to the `returnTo`', async () => { 258 | const nextHeaders = await headers(); 259 | 260 | nextHeaders.set('x-workos-middleware', 'true'); 261 | 262 | await signOut({ returnTo: 'https://example.com/signed-out' }); 263 | 264 | expect(redirect).toHaveBeenCalledTimes(1); 265 | expect(redirect).toHaveBeenCalledWith('https://example.com/signed-out'); 266 | }); 267 | }); 268 | }); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /__tests__/authkit-callback-route.spec.ts: -------------------------------------------------------------------------------- 1 | import { getWorkOS } from '../src/workos.js'; 2 | import { handleAuth } from '../src/authkit-callback-route.js'; 3 | import { NextRequest, NextResponse } from 'next/server.js'; 4 | 5 | // Mocked in jest.setup.ts 6 | import { cookies, headers } from 'next/headers.js'; 7 | 8 | // Mock dependencies 9 | const fakeWorkosInstance = { 10 | userManagement: { 11 | authenticateWithCode: jest.fn(), 12 | getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'), 13 | }, 14 | }; 15 | 16 | jest.mock('../src/workos', () => ({ 17 | getWorkOS: jest.fn(() => fakeWorkosInstance), 18 | })); 19 | 20 | describe('authkit-callback-route', () => { 21 | const workos = getWorkOS(); 22 | const mockAuthResponse = { 23 | accessToken: 'access123', 24 | refreshToken: 'refresh123', 25 | user: { 26 | id: 'user_123', 27 | email: 'test@example.com', 28 | emailVerified: true, 29 | profilePictureUrl: 'https://example.com/photo.jpg', 30 | firstName: 'Test', 31 | lastName: 'User', 32 | object: 'user' as const, 33 | createdAt: '2024-01-01T00:00:00Z', 34 | updatedAt: '2024-01-01T00:00:00Z', 35 | }, 36 | oauthTokens: { 37 | accessToken: 'access123', 38 | refreshToken: 'refresh123', 39 | expiresAt: 1719811200, 40 | scopes: ['foo', 'bar'], 41 | }, 42 | }; 43 | 44 | describe('handleAuth', () => { 45 | let request: NextRequest; 46 | 47 | beforeAll(() => { 48 | // Silence console.error during tests 49 | jest.spyOn(console, 'error').mockImplementation(() => {}); 50 | }); 51 | 52 | beforeEach(async () => { 53 | // Reset all mocks 54 | jest.clearAllMocks(); 55 | 56 | // Create a new request with searchParams 57 | request = new NextRequest(new URL('http://example.com/callback')); 58 | 59 | // Reset the cookie store 60 | const nextCookies = await cookies(); 61 | // @ts-expect-error - _reset is part of the mock 62 | nextCookies._reset(); 63 | 64 | const nextHeaders = await headers(); 65 | // @ts-expect-error - _reset is part of the mock 66 | nextHeaders._reset(); 67 | }); 68 | 69 | it('should handle successful authentication', async () => { 70 | jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse); 71 | 72 | // Set up request with code 73 | request.nextUrl.searchParams.set('code', 'test-code'); 74 | 75 | const handler = handleAuth(); 76 | const response = await handler(request); 77 | 78 | expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({ 79 | clientId: process.env.WORKOS_CLIENT_ID, 80 | code: 'test-code', 81 | }); 82 | expect(response).toBeInstanceOf(NextResponse); 83 | }); 84 | 85 | it('should handle authentication failure', async () => { 86 | // Mock authentication failure 87 | (workos.userManagement.authenticateWithCode as jest.Mock).mockRejectedValue(new Error('Auth failed')); 88 | 89 | request.nextUrl.searchParams.set('code', 'invalid-code'); 90 | 91 | const handler = handleAuth(); 92 | const response = await handler(request); 93 | 94 | expect(response.status).toBe(500); 95 | const data = await response.json(); 96 | expect(data.error.message).toBe('Something went wrong'); 97 | }); 98 | 99 | it('should handle authentication failure if a non-Error object is thrown', async () => { 100 | // Mock authentication failure 101 | jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed'); 102 | 103 | request.nextUrl.searchParams.set('code', 'invalid-code'); 104 | 105 | const handler = handleAuth(); 106 | const response = await handler(request); 107 | 108 | expect(response.status).toBe(500); 109 | const data = await response.json(); 110 | expect(data.error.message).toBe('Something went wrong'); 111 | }); 112 | 113 | it('should handle authentication failure with custom onError handler', async () => { 114 | // Mock authentication failure 115 | jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed'); 116 | request.nextUrl.searchParams.set('code', 'invalid-code'); 117 | 118 | const handler = handleAuth({ 119 | onError: ({ error }) => { 120 | return new Response(JSON.stringify({ error: { message: 'Custom error' } }), { 121 | status: 500, 122 | headers: { 'Content-Type': 'application/json' }, 123 | }); 124 | }, 125 | }); 126 | const response = await handler(request); 127 | 128 | expect(response.status).toBe(500); 129 | const data = await response.json(); 130 | expect(data.error.message).toBe('Custom error'); 131 | }); 132 | 133 | it('should handle missing code parameter', async () => { 134 | const handler = handleAuth(); 135 | const response = await handler(request); 136 | 137 | expect(response.status).toBe(500); 138 | const data = await response.json(); 139 | expect(data.error.message).toBe('Something went wrong'); 140 | }); 141 | 142 | it('should respect custom returnPathname', async () => { 143 | jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse); 144 | 145 | request.nextUrl.searchParams.set('code', 'test-code'); 146 | 147 | const handler = handleAuth({ returnPathname: '/dashboard' }); 148 | const response = await handler(request); 149 | 150 | expect(response.headers.get('Location')).toContain('/dashboard'); 151 | }); 152 | 153 | it('should handle state parameter with returnPathname', async () => { 154 | jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse); 155 | 156 | const state = btoa(JSON.stringify({ returnPathname: '/custom-path' })); 157 | request.nextUrl.searchParams.set('code', 'test-code'); 158 | request.nextUrl.searchParams.set('state', state); 159 | 160 | const handler = handleAuth(); 161 | const response = await handler(request); 162 | 163 | expect(response.headers.get('Location')).toContain('/custom-path'); 164 | }); 165 | 166 | it('should extract custom search params from returnPathname', async () => { 167 | jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse); 168 | 169 | const state = btoa(JSON.stringify({ returnPathname: '/custom-path?foo=bar&baz=qux' })); 170 | request.nextUrl.searchParams.set('code', 'test-code'); 171 | request.nextUrl.searchParams.set('state', state); 172 | 173 | const handler = handleAuth(); 174 | const response = await handler(request); 175 | 176 | expect(response.headers.get('Location')).toContain('/custom-path?foo=bar&baz=qux'); 177 | }); 178 | 179 | it('should use Response if NextResponse.redirect is not available', async () => { 180 | const originalRedirect = NextResponse.redirect; 181 | (NextResponse as Partial).redirect = undefined; 182 | 183 | jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse); 184 | 185 | // Set up request with code 186 | request.nextUrl.searchParams.set('code', 'test-code'); 187 | 188 | const handler = handleAuth(); 189 | const response = await handler(request); 190 | 191 | expect(response).toBeInstanceOf(Response); 192 | 193 | // Restore the original redirect method 194 | (NextResponse as Partial).redirect = originalRedirect; 195 | }); 196 | 197 | it('should use Response if NextResponse.json is not available', async () => { 198 | const originalJson = NextResponse.json; 199 | (NextResponse as Partial).json = undefined; 200 | 201 | const handler = handleAuth(); 202 | const response = await handler(request); 203 | 204 | expect(response).toBeInstanceOf(Response); 205 | 206 | // Restore the original json method 207 | (NextResponse as Partial).json = originalJson; 208 | }); 209 | 210 | it('should throw an error if baseURL is provided but invalid', async () => { 211 | expect(() => handleAuth({ baseURL: 'invalid-url' })).toThrow('Invalid baseURL: invalid-url'); 212 | }); 213 | 214 | it('should use baseURL if provided', async () => { 215 | jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse); 216 | 217 | // Set up request with code 218 | request.nextUrl.searchParams.set('code', 'test-code'); 219 | 220 | const handler = handleAuth({ baseURL: 'https://base.com' }); 221 | const response = await handler(request); 222 | 223 | expect(response.headers.get('Location')).toContain('https://base.com'); 224 | }); 225 | 226 | it('should throw an error if response is missing tokens', async () => { 227 | const mockAuthResponse = { 228 | user: { id: 'user_123' }, 229 | }; 230 | 231 | (workos.userManagement.authenticateWithCode as jest.Mock).mockResolvedValue(mockAuthResponse); 232 | 233 | // Set up request with code 234 | request.nextUrl.searchParams.set('code', 'test-code'); 235 | 236 | const handler = handleAuth(); 237 | const response = await handler(request); 238 | 239 | expect(response.status).toBe(500); 240 | }); 241 | 242 | it('should call onSuccess if provided', async () => { 243 | jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse); 244 | 245 | // Set up request with code 246 | request.nextUrl.searchParams.set('code', 'test-code'); 247 | 248 | const onSuccess = jest.fn(); 249 | const handler = handleAuth({ onSuccess: onSuccess }); 250 | await handler(request); 251 | 252 | expect(onSuccess).toHaveBeenCalledWith(mockAuthResponse); 253 | }); 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /__tests__/authkit-provider.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitFor, act } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import { AuthKitProvider, useAuth } from '../src/components/authkit-provider.js'; 5 | import { 6 | checkSessionAction, 7 | getAuthAction, 8 | refreshAuthAction, 9 | handleSignOutAction, 10 | switchToOrganizationAction, 11 | } from '../src/actions.js'; 12 | 13 | jest.mock('../src/actions', () => ({ 14 | checkSessionAction: jest.fn(), 15 | getAuthAction: jest.fn(), 16 | refreshAuthAction: jest.fn(), 17 | handleSignOutAction: jest.fn(), 18 | switchToOrganizationAction: jest.fn(), 19 | })); 20 | 21 | describe('AuthKitProvider', () => { 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | it('should render children', async () => { 27 | const { getByText } = await act(async () => { 28 | return render( 29 | 30 |
Test Child
31 |
, 32 | ); 33 | }); 34 | 35 | expect(getByText('Test Child')).toBeInTheDocument(); 36 | }); 37 | 38 | it('should do nothing if onSessionExpired is false', async () => { 39 | jest.spyOn(window, 'addEventListener'); 40 | 41 | await act(async () => { 42 | render( 43 | 44 |
Test Child
45 |
, 46 | ); 47 | }); 48 | 49 | // expect window to not have an event listener 50 | expect(window.addEventListener).not.toHaveBeenCalled(); 51 | }); 52 | 53 | it('should call onSessionExpired when session is expired', async () => { 54 | (checkSessionAction as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch')); 55 | const onSessionExpired = jest.fn(); 56 | 57 | render( 58 | 59 |
Test Child
60 |
, 61 | ); 62 | 63 | act(() => { 64 | // Simulate visibility change 65 | window.dispatchEvent(new Event('visibilitychange')); 66 | }); 67 | 68 | await waitFor(() => { 69 | expect(onSessionExpired).toHaveBeenCalled(); 70 | }); 71 | }); 72 | 73 | it('should only call onSessionExpired once if multiple visibility changes occur', async () => { 74 | (checkSessionAction as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch')); 75 | const onSessionExpired = jest.fn(); 76 | 77 | render( 78 | 79 |
Test Child
80 |
, 81 | ); 82 | 83 | act(() => { 84 | // Simulate visibility change twice 85 | window.dispatchEvent(new Event('visibilitychange')); 86 | window.dispatchEvent(new Event('visibilitychange')); 87 | }); 88 | 89 | await waitFor(() => { 90 | expect(onSessionExpired).toHaveBeenCalledTimes(1); 91 | }); 92 | }); 93 | 94 | it('should pass through if checkSessionAction does not throw "Failed to fetch"', async () => { 95 | (checkSessionAction as jest.Mock).mockResolvedValueOnce(false); 96 | 97 | const onSessionExpired = jest.fn(); 98 | 99 | render( 100 | 101 |
Test Child
102 |
, 103 | ); 104 | 105 | act(() => { 106 | // Simulate visibility change 107 | window.dispatchEvent(new Event('visibilitychange')); 108 | }); 109 | 110 | await waitFor(() => { 111 | expect(onSessionExpired).not.toHaveBeenCalled(); 112 | }); 113 | }); 114 | 115 | it('should reload the page when session is expired and no onSessionExpired handler is provided', async () => { 116 | (checkSessionAction as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch')); 117 | 118 | const originalLocation = window.location; 119 | 120 | // @ts-expect-error - we're deleting the property to test the mock 121 | delete window.location; 122 | 123 | window.location = { ...window.location, reload: jest.fn() }; 124 | 125 | render( 126 | 127 |
Test Child
128 |
, 129 | ); 130 | 131 | act(() => { 132 | // Simulate visibility change 133 | window.dispatchEvent(new Event('visibilitychange')); 134 | }); 135 | 136 | await waitFor(() => { 137 | expect(window.location.reload).toHaveBeenCalled(); 138 | }); 139 | 140 | // Restore original reload function 141 | window.location = originalLocation; 142 | }); 143 | 144 | it('should not call onSessionExpired or reload the page if session is valid', async () => { 145 | (checkSessionAction as jest.Mock).mockResolvedValueOnce(true); 146 | const onSessionExpired = jest.fn(); 147 | 148 | const originalLocation = window.location; 149 | 150 | // @ts-expect-error - we're deleting the property to test the mock 151 | delete window.location; 152 | 153 | window.location = { ...window.location, reload: jest.fn() }; 154 | 155 | render( 156 | 157 |
Test Child
158 |
, 159 | ); 160 | 161 | act(() => { 162 | // Simulate visibility change 163 | window.dispatchEvent(new Event('visibilitychange')); 164 | }); 165 | 166 | await waitFor(() => { 167 | expect(onSessionExpired).not.toHaveBeenCalled(); 168 | expect(window.location.reload).not.toHaveBeenCalled(); 169 | }); 170 | 171 | window.location = originalLocation; 172 | }); 173 | }); 174 | 175 | describe('useAuth', () => { 176 | beforeEach(() => { 177 | jest.clearAllMocks(); 178 | }); 179 | 180 | it('should call getAuth when a user is not returned when ensureSignedIn is true', async () => { 181 | // First and second calls return no user, second call returns a user 182 | (getAuthAction as jest.Mock) 183 | .mockResolvedValueOnce({ user: null, loading: true }) 184 | .mockResolvedValueOnce({ user: { email: 'test@example.com' }, loading: false }); 185 | 186 | const TestComponent = () => { 187 | const auth = useAuth({ ensureSignedIn: true }); 188 | return
{auth.user?.email}
; 189 | }; 190 | 191 | const { getByTestId } = render( 192 | 193 | 194 | , 195 | ); 196 | 197 | await waitFor(() => { 198 | expect(getAuthAction).toHaveBeenCalledTimes(2); 199 | expect(getAuthAction).toHaveBeenLastCalledWith({ ensureSignedIn: true }); 200 | expect(getByTestId('email')).toHaveTextContent('test@example.com'); 201 | }); 202 | }); 203 | 204 | it('should throw error when used outside of AuthKitProvider', () => { 205 | const TestComponent = () => { 206 | const auth = useAuth(); 207 | return
{auth.user?.email}
; 208 | }; 209 | 210 | // Suppress console.error for this test since we expect an error 211 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 212 | 213 | expect(() => { 214 | render(); 215 | }).toThrow('useAuth must be used within an AuthKitProvider'); 216 | 217 | consoleSpy.mockRestore(); 218 | }); 219 | 220 | it('should provide auth context values when used within AuthKitProvider', async () => { 221 | (getAuthAction as jest.Mock).mockResolvedValueOnce({ 222 | user: { email: 'test@example.com' }, 223 | sessionId: 'test-session', 224 | organizationId: 'test-org', 225 | role: 'admin', 226 | permissions: ['read', 'write'], 227 | entitlements: ['feature1'], 228 | impersonator: { email: 'admin@example.com' }, 229 | }); 230 | 231 | const TestComponent = () => { 232 | const auth = useAuth(); 233 | return ( 234 |
235 |
{auth.loading.toString()}
236 |
{auth.user?.email}
237 |
{auth.sessionId}
238 |
{auth.organizationId}
239 |
240 | ); 241 | }; 242 | 243 | const { getByTestId } = render( 244 | 245 | 246 | , 247 | ); 248 | 249 | // Initially loading 250 | expect(getByTestId('loading')).toHaveTextContent('true'); 251 | 252 | // Wait for auth to load 253 | await waitFor(() => { 254 | expect(getByTestId('loading')).toHaveTextContent('false'); 255 | expect(getByTestId('email')).toHaveTextContent('test@example.com'); 256 | expect(getByTestId('session')).toHaveTextContent('test-session'); 257 | expect(getByTestId('org')).toHaveTextContent('test-org'); 258 | }); 259 | }); 260 | 261 | it('should handle auth methods (getAuth and refreshAuth)', async () => { 262 | const mockAuth = { 263 | user: { email: 'test@example.com' }, 264 | sessionId: 'test-session', 265 | }; 266 | 267 | (getAuthAction as jest.Mock).mockResolvedValueOnce(mockAuth); 268 | (refreshAuthAction as jest.Mock).mockResolvedValueOnce({ 269 | ...mockAuth, 270 | sessionId: 'new-session', 271 | }); 272 | 273 | const TestComponent = () => { 274 | const auth = useAuth(); 275 | return ( 276 |
277 |
{auth.sessionId}
278 | 279 |
280 | ); 281 | }; 282 | 283 | const { getByTestId, getByRole } = render( 284 | 285 | 286 | , 287 | ); 288 | 289 | await waitFor(() => { 290 | expect(getByTestId('session')).toHaveTextContent('test-session'); 291 | }); 292 | 293 | // Test refresh 294 | act(() => { 295 | getByRole('button').click(); 296 | }); 297 | 298 | await waitFor(() => { 299 | expect(getByTestId('session')).toHaveTextContent('new-session'); 300 | }); 301 | }); 302 | 303 | it('should handle switching organizations', async () => { 304 | const mockAuth = { 305 | user: { email: 'test@example.com' }, 306 | sessionId: 'test-session', 307 | organizationId: 'new-org', 308 | }; 309 | 310 | (getAuthAction as jest.Mock) 311 | .mockResolvedValue(mockAuth) 312 | .mockResolvedValueOnce({ ...mockAuth, organizationId: 'old-org' }); 313 | (switchToOrganizationAction as jest.Mock).mockResolvedValueOnce(mockAuth); 314 | 315 | const TestComponent = () => { 316 | const auth = useAuth(); 317 | return ( 318 |
319 |
{auth.organizationId}
320 | 321 |
322 | ); 323 | }; 324 | 325 | const { getByTestId, getByRole } = render( 326 | 327 | 328 | , 329 | ); 330 | 331 | await waitFor(() => { 332 | expect(getByTestId('org')).toHaveTextContent('old-org'); 333 | }); 334 | 335 | // Test refresh 336 | act(() => { 337 | getByRole('button').click(); 338 | }); 339 | 340 | await waitFor(() => { 341 | expect(getByTestId('org')).toHaveTextContent('new-org'); 342 | }); 343 | }); 344 | 345 | it('should receive an error when refreshAuth fails with an error', async () => { 346 | (refreshAuthAction as jest.Mock).mockRejectedValueOnce(new Error('Refresh failed')); 347 | 348 | let error: string | undefined; 349 | 350 | const TestComponent = () => { 351 | const auth = useAuth(); 352 | return ( 353 |
354 |
{auth.sessionId}
355 | 363 |
364 | ); 365 | }; 366 | 367 | const { getByRole } = render( 368 | 369 | 370 | , 371 | ); 372 | 373 | act(() => { 374 | getByRole('button').click(); 375 | }); 376 | 377 | await waitFor(() => { 378 | expect(error).toBe('Refresh failed'); 379 | }); 380 | }); 381 | 382 | it('should receive an error when refreshAuth fails with a string error', async () => { 383 | (refreshAuthAction as jest.Mock).mockRejectedValueOnce('Refresh failed'); 384 | 385 | let error: string | undefined; 386 | 387 | const TestComponent = () => { 388 | const auth = useAuth(); 389 | return ( 390 |
391 |
{auth.sessionId}
392 | 400 |
401 | ); 402 | }; 403 | 404 | const { getByRole } = render( 405 | 406 | 407 | , 408 | ); 409 | 410 | act(() => { 411 | getByRole('button').click(); 412 | }); 413 | 414 | await waitFor(() => { 415 | expect(error).toBe('Refresh failed'); 416 | }); 417 | }); 418 | 419 | it('should call handleSignOutAction when signOut is called', async () => { 420 | (handleSignOutAction as jest.Mock).mockResolvedValueOnce({}); 421 | 422 | const TestComponent = () => { 423 | const auth = useAuth(); 424 | return ( 425 |
426 |
{auth.sessionId}
427 | 428 |
429 | ); 430 | }; 431 | 432 | const { getByRole } = render( 433 | 434 | 435 | , 436 | ); 437 | 438 | await act(async () => { 439 | getByRole('button').click(); 440 | }); 441 | 442 | expect(handleSignOutAction).toHaveBeenCalled(); 443 | }); 444 | 445 | it('should pass returnTo parameter to handleSignOutAction', async () => { 446 | (handleSignOutAction as jest.Mock).mockResolvedValueOnce({}); 447 | 448 | const TestComponent = () => { 449 | const auth = useAuth(); 450 | return ( 451 |
452 |
{auth.sessionId}
453 | 454 |
455 | ); 456 | }; 457 | 458 | const { getByRole } = render( 459 | 460 | 461 | , 462 | ); 463 | 464 | await act(async () => { 465 | getByRole('button').click(); 466 | }); 467 | 468 | expect(handleSignOutAction).toHaveBeenCalledWith({ returnTo: '/home' }); 469 | }); 470 | }); 471 | -------------------------------------------------------------------------------- /__tests__/button.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import { Button } from '../src/components/button.js'; 5 | 6 | describe('Button', () => { 7 | it('should render with default props', () => { 8 | const { getByRole } = render(); 9 | const button = getByRole('button'); 10 | 11 | expect(button).toBeInTheDocument(); 12 | expect(button).toHaveTextContent('Click me'); 13 | expect(button).toHaveAttribute('type', 'button'); 14 | }); 15 | 16 | it('should forward ref correctly', () => { 17 | const ref = React.createRef(); 18 | render(); 19 | 20 | expect(ref.current).toBeInstanceOf(HTMLButtonElement); 21 | }); 22 | 23 | it('should merge custom styles with default styles', () => { 24 | const { getByRole } = render(); 25 | const button = getByRole('button'); 26 | 27 | expect(button).toHaveStyle({ 28 | backgroundColor: 'red', 29 | display: 'inline-flex', 30 | alignItems: 'center', 31 | justifyContent: 'center', 32 | }); 33 | }); 34 | 35 | it('should pass through additional props', () => { 36 | const { getByRole } = render( 37 | , 40 | ); 41 | const button = getByRole('button'); 42 | 43 | expect(button).toHaveAttribute('data-testid', 'test-button'); 44 | expect(button).toHaveAttribute('aria-label', 'Test Button'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /__tests__/cookie.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from '@jest/globals'; 2 | 3 | // Mock at the top of the file 4 | jest.mock('../src/env-variables'); 5 | 6 | describe('cookie.ts', () => { 7 | beforeEach(() => { 8 | // Clear all mocks before each test 9 | jest.clearAllMocks(); 10 | // Reset modules 11 | jest.resetModules(); 12 | }); 13 | 14 | describe('getCookieOptions', () => { 15 | it('should return the default cookie options', async () => { 16 | const { getCookieOptions } = await import('../src/cookie'); 17 | 18 | const options = getCookieOptions(); 19 | expect(options).toEqual( 20 | expect.objectContaining({ 21 | path: '/', 22 | httpOnly: true, 23 | secure: false, 24 | sameSite: 'lax', 25 | maxAge: 400 * 24 * 60 * 60, 26 | domain: 'example.com', 27 | }), 28 | ); 29 | }); 30 | 31 | it('should return the cookie options with custom values', async () => { 32 | // Import the mocked module 33 | const envVars = await import('../src/env-variables'); 34 | 35 | // Set the mock values 36 | Object.defineProperty(envVars, 'WORKOS_COOKIE_MAX_AGE', { value: '1000' }); 37 | Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: 'foobar.com' }); 38 | 39 | const { getCookieOptions } = await import('../src/cookie'); 40 | const options = getCookieOptions('http://example.com'); 41 | 42 | expect(options).toEqual( 43 | expect.objectContaining({ 44 | secure: false, 45 | maxAge: 1000, 46 | domain: 'foobar.com', 47 | }), 48 | ); 49 | 50 | Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: '' }); 51 | 52 | const options2 = getCookieOptions('http://example.com'); 53 | expect(options2).toEqual( 54 | expect.objectContaining({ 55 | secure: false, 56 | maxAge: 1000, 57 | domain: '', 58 | }), 59 | ); 60 | 61 | const options3 = getCookieOptions('https://example.com', true); 62 | 63 | expect(options3).toEqual(expect.stringContaining('Domain=')); 64 | }); 65 | 66 | it('should return the cookie options with expired set to true', async () => { 67 | const { getCookieOptions } = await import('../src/cookie'); 68 | const options = getCookieOptions('http://example.com', false, true); 69 | expect(options).toEqual(expect.objectContaining({ maxAge: 0 })); 70 | }); 71 | 72 | it('should return the cookie options as a string', async () => { 73 | const { getCookieOptions } = await import('../src/cookie'); 74 | const options = getCookieOptions('http://example.com', true, false); 75 | expect(options).toEqual( 76 | expect.stringContaining('Path=/; HttpOnly; Secure=false; SameSite=lax; Max-Age=34560000; Domain=example.com'), 77 | ); 78 | 79 | const options2 = getCookieOptions('https://example.com', true, true); 80 | expect(options2).toEqual( 81 | expect.stringContaining('Path=/; HttpOnly; Secure=true; SameSite=lax; Max-Age=0; Domain=example.com'), 82 | ); 83 | }); 84 | 85 | it('allows the sameSite config to be set by the WORKOS_COOKIE_SAMESITE env variable', async () => { 86 | const envVars = await import('../src/env-variables'); 87 | Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' }); 88 | 89 | const { getCookieOptions } = await import('../src/cookie'); 90 | const options = getCookieOptions('http://example.com'); 91 | expect(options).toEqual(expect.objectContaining({ sameSite: 'none' })); 92 | }); 93 | 94 | it('throws an error if the sameSite value is invalid', async () => { 95 | const envVars = await import('../src/env-variables'); 96 | Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'invalid' }); 97 | 98 | const { getCookieOptions } = await import('../src/cookie'); 99 | expect(() => getCookieOptions('http://example.com')).toThrow('Invalid SameSite value: invalid'); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /__tests__/get-authorization-url.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { getAuthorizationUrl } from '../src/get-authorization-url.js'; 3 | import { headers } from 'next/headers.js'; 4 | import { getWorkOS } from '../src/workos.js'; 5 | 6 | jest.mock('next/headers.js'); 7 | 8 | // Mock dependencies 9 | const fakeWorkosInstance = { 10 | userManagement: { 11 | getAuthorizationUrl: jest.fn(), 12 | }, 13 | }; 14 | 15 | jest.mock('../src/workos', () => ({ 16 | getWorkOS: jest.fn(() => fakeWorkosInstance), 17 | })); 18 | 19 | describe('getAuthorizationUrl', () => { 20 | const workos = getWorkOS(); 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | it('uses x-redirect-uri header when redirectUri option is not provided', async () => { 26 | const nextHeaders = await headers(); 27 | nextHeaders.set('x-redirect-uri', 'http://test-redirect.com'); 28 | 29 | // Mock workos.userManagement.getAuthorizationUrl 30 | jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url'); 31 | 32 | await getAuthorizationUrl({}); 33 | 34 | expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith( 35 | expect.objectContaining({ 36 | redirectUri: 'http://test-redirect.com', 37 | }), 38 | ); 39 | }); 40 | 41 | it('works when called with no arguments', async () => { 42 | jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url'); 43 | 44 | await getAuthorizationUrl(); // Call with no arguments 45 | 46 | expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalled(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /__tests__/impersonation.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, act, screen } from '@testing-library/react'; 2 | import '@testing-library/jest-dom'; 3 | import { Impersonation } from '../src/components/impersonation.js'; 4 | import { useAuth } from '../src/components/authkit-provider.js'; 5 | import { getOrganizationAction } from '../src/actions.js'; 6 | import * as React from 'react'; 7 | import { handleSignOutAction } from '../src/actions.js'; 8 | 9 | // Mock the useAuth hook 10 | jest.mock('../src/components/authkit-provider', () => ({ 11 | useAuth: jest.fn(), 12 | })); 13 | 14 | // Mock the getOrganizationAction 15 | jest.mock('../src/actions', () => ({ 16 | getOrganizationAction: jest.fn(), 17 | handleSignOutAction: jest.fn(), 18 | })); 19 | 20 | describe('Impersonation', () => { 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | it('should return null if not impersonating', () => { 26 | (useAuth as jest.Mock).mockReturnValue({ 27 | impersonator: null, 28 | user: { id: '123', email: 'user@example.com' }, 29 | organizationId: null, 30 | loading: false, 31 | }); 32 | 33 | const { container } = render(); 34 | expect(container).toBeEmptyDOMElement(); 35 | }); 36 | 37 | it('should return null if loading', () => { 38 | (useAuth as jest.Mock).mockReturnValue({ 39 | impersonator: { email: 'admin@example.com' }, 40 | user: { id: '123', email: 'user@example.com' }, 41 | organizationId: null, 42 | loading: true, 43 | }); 44 | 45 | const { container } = render(); 46 | expect(container).toBeEmptyDOMElement(); 47 | }); 48 | 49 | it('should render impersonation banner when impersonating', () => { 50 | (useAuth as jest.Mock).mockReturnValue({ 51 | impersonator: { email: 'admin@example.com' }, 52 | user: { id: '123', email: 'user@example.com' }, 53 | organizationId: null, 54 | loading: false, 55 | }); 56 | 57 | const { container } = render(); 58 | expect(container.querySelector('[data-workos-impersonation-root]')).toBeInTheDocument(); 59 | }); 60 | 61 | it('should render with organization info when organizationId is provided', async () => { 62 | (useAuth as jest.Mock).mockReturnValue({ 63 | impersonator: { email: 'admin@example.com' }, 64 | user: { id: '123', email: 'user@example.com' }, 65 | organizationId: 'org_123', 66 | loading: false, 67 | }); 68 | 69 | (getOrganizationAction as jest.Mock).mockResolvedValue({ 70 | id: 'org_123', 71 | name: 'Test Org', 72 | }); 73 | 74 | const { container } = await act(async () => { 75 | return render(); 76 | }); 77 | 78 | expect(container.querySelector('[data-workos-impersonation-root]')).toBeInTheDocument(); 79 | }); 80 | 81 | it('should render at the bottom by default', () => { 82 | (useAuth as jest.Mock).mockReturnValue({ 83 | impersonator: { email: 'admin@example.com' }, 84 | user: { id: '123', email: 'user@example.com' }, 85 | organizationId: null, 86 | loading: false, 87 | }); 88 | 89 | const { container } = render(); 90 | const banner = container.querySelector('[data-workos-impersonation-root] > div:nth-child(2)'); 91 | expect(banner).toHaveStyle({ bottom: 'var(--wi-s)' }); 92 | }); 93 | 94 | it('should render at the top when side prop is "top"', () => { 95 | (useAuth as jest.Mock).mockReturnValue({ 96 | impersonator: { email: 'admin@example.com' }, 97 | user: { id: '123', email: 'user@example.com' }, 98 | organizationId: null, 99 | loading: false, 100 | }); 101 | 102 | const { container } = render(); 103 | const banner = container.querySelector('[data-workos-impersonation-root] > div:nth-child(2)'); 104 | expect(banner).toHaveStyle({ top: 'var(--wi-s)' }); 105 | }); 106 | 107 | it('should merge custom styles with default styles', () => { 108 | (useAuth as jest.Mock).mockReturnValue({ 109 | impersonator: { email: 'admin@example.com' }, 110 | user: { id: '123', email: 'user@example.com' }, 111 | organizationId: null, 112 | loading: false, 113 | }); 114 | 115 | const customStyle = { backgroundColor: 'red' }; 116 | const { container } = render(); 117 | const root = container.querySelector('[data-workos-impersonation-root]'); 118 | expect(root).toHaveStyle({ backgroundColor: 'red' }); 119 | }); 120 | 121 | it('should should sign out when the Stop button is called', async () => { 122 | (useAuth as jest.Mock).mockReturnValue({ 123 | impersonator: { email: 'admin@example.com' }, 124 | user: { id: '123', email: 'user@example.com' }, 125 | organizationId: null, 126 | loading: false, 127 | }); 128 | 129 | render(); 130 | const stopButton = await screen.findByText('Stop'); 131 | stopButton.click(); 132 | expect(handleSignOutAction).toHaveBeenCalled(); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /__tests__/min-max-button.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, act } from '@testing-library/react'; 2 | import { MinMaxButton } from '../src/components/min-max-button.js'; 3 | import * as React from 'react'; 4 | import '@testing-library/jest-dom'; 5 | 6 | describe('MinMaxButton', () => { 7 | beforeEach(() => { 8 | // Create the root element before each test 9 | const root = document.createElement('div'); 10 | root.setAttribute('data-workos-impersonation-root', ''); 11 | document.body.appendChild(root); 12 | }); 13 | 14 | afterEach(() => { 15 | // Clean up after each test 16 | document.body.innerHTML = ''; 17 | }); 18 | 19 | it('sets minimized value when clicked', () => { 20 | const { getByRole } = render(Minimize); 21 | 22 | act(() => { 23 | getByRole('button').click(); 24 | }); 25 | 26 | const root = document.querySelector('[data-workos-impersonation-root]'); 27 | expect(root).toHaveStyle({ '--wi-minimized': '1' }); 28 | }); 29 | 30 | it('does nothing if root is undefined', () => { 31 | const { getByRole } = render(Minimize); 32 | 33 | const root = document.querySelector('[data-workos-impersonation-root]'); 34 | 35 | // Mock querySelector to return null for this test 36 | jest.spyOn(document, 'querySelector').mockReturnValue(null); 37 | 38 | act(() => { 39 | getByRole('button').click(); 40 | }); 41 | 42 | expect(root).not.toHaveStyle({ '--wi-minimized': '1' }); 43 | }); 44 | 45 | it('renders children correctly', () => { 46 | const { getByText } = render(Test Child); 47 | 48 | expect(getByText('Test Child')).toBeInTheDocument(); 49 | }); 50 | 51 | it('applies correct default styling', () => { 52 | const { getByRole } = render(Test); 53 | 54 | const button = getByRole('button'); 55 | expect(button).toHaveStyle({ 56 | padding: 0, 57 | width: '1.714em', 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /__tests__/test-helpers.ts: -------------------------------------------------------------------------------- 1 | // istanbul ignore file 2 | 3 | import { sealData } from 'iron-session'; 4 | import { SignJWT } from 'jose'; 5 | import { WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD } from '../src/env-variables.js'; 6 | import { cookies } from 'next/headers.js'; 7 | import { User } from '@workos-inc/node'; 8 | 9 | export async function generateTestToken(payload = {}, expired = false) { 10 | const defaultPayload = { 11 | sid: 'session_123', 12 | org_id: 'org_123', 13 | role: 'member', 14 | permissions: ['posts:create', 'posts:delete'], 15 | entitlements: ['audit-logs'], 16 | }; 17 | 18 | const mergedPayload = { ...defaultPayload, ...payload }; 19 | 20 | const secret = new TextEncoder().encode(process.env.WORKOS_COOKIE_PASSWORD as string); 21 | 22 | const token = await new SignJWT(mergedPayload) 23 | .setProtectedHeader({ alg: 'HS256' }) 24 | .setIssuedAt() 25 | .setIssuer('urn:example:issuer') 26 | .setExpirationTime(expired ? '0s' : '2h') 27 | .sign(secret); 28 | 29 | return token; 30 | } 31 | 32 | export async function generateSession(overrides: Partial = {}) { 33 | const mockUser = { 34 | id: 'user_123', 35 | email: 'test@example.com', 36 | emailVerified: true, 37 | profilePictureUrl: null, 38 | firstName: 'Test', 39 | lastName: 'User', 40 | object: 'user', 41 | createdAt: '2024-01-01T00:00:00Z', 42 | updatedAt: '2024-01-01T00:00:00Z', 43 | ...overrides, 44 | } satisfies User; 45 | 46 | const accessToken = await generateTestToken({ 47 | sid: 'session_123', 48 | org_id: 'org_123', 49 | }); 50 | 51 | // Create and set a session cookie 52 | const encryptedSession = await sealData( 53 | { 54 | accessToken, 55 | refreshToken: 'refresh_token_123', 56 | user: mockUser, 57 | }, 58 | { 59 | password: WORKOS_COOKIE_PASSWORD as string, 60 | }, 61 | ); 62 | 63 | const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; 64 | const nextCookies = await cookies(); 65 | nextCookies.set(cookieName, encryptedSession); 66 | } 67 | -------------------------------------------------------------------------------- /__tests__/useAccessToken.spec.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { act, render, waitFor } from '@testing-library/react'; 3 | import React from 'react'; 4 | import { getAccessTokenAction, refreshAccessTokenAction } from '../src/actions.js'; 5 | import { useAuth } from '../src/components/authkit-provider.js'; 6 | import { useAccessToken } from '../src/components/useAccessToken.js'; 7 | 8 | jest.mock('../src/actions.js', () => ({ 9 | getAccessTokenAction: jest.fn(), 10 | refreshAccessTokenAction: jest.fn(), 11 | })); 12 | 13 | jest.mock('../src/components/authkit-provider.js', () => { 14 | const originalModule = jest.requireActual('../src/components/authkit-provider.js'); 15 | return { 16 | ...originalModule, 17 | useAuth: jest.fn(), 18 | }; 19 | }); 20 | 21 | describe('useAccessToken', () => { 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | jest.useFakeTimers(); 25 | 26 | (useAuth as jest.Mock).mockImplementation(() => ({ 27 | user: { id: 'user_123' }, 28 | sessionId: 'session_123', 29 | refreshAuth: jest.fn().mockResolvedValue({}), 30 | })); 31 | }); 32 | 33 | afterEach(() => { 34 | jest.useRealTimers(); 35 | }); 36 | 37 | const TestComponent = () => { 38 | const { accessToken, loading, error, refresh } = useAccessToken(); 39 | return ( 40 |
41 |
{accessToken || 'no-token'}
42 |
{loading.toString()}
43 |
{error?.message || 'no-error'}
44 | 47 |
48 | ); 49 | }; 50 | 51 | it('should fetch an access token on mount', async () => { 52 | const mockToken = 53 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 54 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(mockToken); 55 | 56 | const { getByTestId } = render(); 57 | 58 | expect(getByTestId('loading')).toHaveTextContent('true'); 59 | 60 | await waitFor(() => { 61 | expect(getByTestId('loading')).toHaveTextContent('false'); 62 | expect(getByTestId('token')).toHaveTextContent(mockToken); 63 | }); 64 | 65 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 66 | }); 67 | 68 | it('should handle token refresh when an expiring token is received', async () => { 69 | // Create a token that's about to expire (exp is very close to current time) 70 | const currentTimeInSeconds = Math.floor(Date.now() / 1000); 71 | const payload = { sub: '1234567890', sid: 'session_123', exp: currentTimeInSeconds + 30 }; 72 | const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 73 | 74 | const refreshedToken = 75 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 76 | 77 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(expiringToken); 78 | (refreshAccessTokenAction as jest.Mock).mockResolvedValueOnce(refreshedToken); 79 | 80 | const { getByTestId } = render(); 81 | 82 | await waitFor(() => { 83 | expect(getByTestId('loading')).toHaveTextContent('false'); 84 | expect(getByTestId('token')).toHaveTextContent(refreshedToken); 85 | }); 86 | 87 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 88 | expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); 89 | }); 90 | 91 | it('should handle token refresh on manual refresh', async () => { 92 | const initialToken = 93 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 94 | const refreshedToken = 95 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2'; 96 | 97 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(initialToken).mockResolvedValueOnce(refreshedToken); 98 | 99 | const mockRefreshAuth = jest.fn().mockResolvedValue({}); 100 | (useAuth as jest.Mock).mockImplementation(() => ({ 101 | user: { id: 'user_123' }, 102 | sessionId: 'session_123', 103 | refreshAuth: mockRefreshAuth, 104 | })); 105 | 106 | const { getByTestId } = render(); 107 | 108 | await waitFor(() => { 109 | expect(getByTestId('token')).toHaveTextContent(initialToken); 110 | }); 111 | 112 | act(() => { 113 | getByTestId('refresh').click(); 114 | }); 115 | 116 | await waitFor(() => { 117 | expect(mockRefreshAuth).toHaveBeenCalledTimes(1); 118 | expect(getAccessTokenAction).toHaveBeenCalledTimes(2); 119 | expect(getByTestId('token')).toHaveTextContent(refreshedToken); 120 | }); 121 | }); 122 | 123 | it('should schedule automatic token refresh before expiration', async () => { 124 | // Create a token that expires in 2 minutes 125 | const currentTimeInSeconds = Math.floor(Date.now() / 1000); 126 | const expTimeInSeconds = currentTimeInSeconds + 120; // 2 minutes in future 127 | const payload = { sub: '1234567890', sid: 'session_123', exp: expTimeInSeconds }; 128 | const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 129 | 130 | const refreshedToken = 131 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2'; 132 | 133 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(token); 134 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(refreshedToken); 135 | 136 | render(); 137 | 138 | await waitFor(() => { 139 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 140 | }); 141 | 142 | act(() => { 143 | jest.advanceTimersByTime((120 - 60) * 1000); 144 | }); 145 | 146 | await waitFor(() => { 147 | expect(getAccessTokenAction).toHaveBeenCalledTimes(2); 148 | }); 149 | }); 150 | 151 | it('should handle the not loggged in state', async () => { 152 | (useAuth as jest.Mock).mockImplementation(() => ({ 153 | user: undefined, 154 | sessionId: undefined, 155 | refreshAuth: jest.fn().mockResolvedValue({}), 156 | })); 157 | 158 | const { getByTestId } = render(); 159 | 160 | await waitFor(() => { 161 | expect(getByTestId('loading')).toHaveTextContent('false'); 162 | expect(getByTestId('token')).toHaveTextContent('no-token'); 163 | }); 164 | }); 165 | 166 | it('should handle errors during token fetch', async () => { 167 | const error = new Error('Failed to fetch token'); 168 | (getAccessTokenAction as jest.Mock).mockRejectedValueOnce(error); 169 | 170 | const { getByTestId } = render(); 171 | 172 | await waitFor(() => { 173 | expect(getByTestId('loading')).toHaveTextContent('false'); 174 | expect(getByTestId('error')).toHaveTextContent('Failed to fetch token'); 175 | expect(getByTestId('token')).toHaveTextContent('no-token'); 176 | }); 177 | }); 178 | 179 | it('should handle errors during manual refresh', async () => { 180 | const initialToken = 181 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 182 | const error = new Error('Failed to refresh token'); 183 | 184 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(initialToken); 185 | 186 | const mockRefreshAuth = jest.fn().mockRejectedValueOnce(error); 187 | (useAuth as jest.Mock).mockImplementation(() => ({ 188 | user: { id: 'user_123' }, 189 | sessionId: 'session_123', 190 | refreshAuth: mockRefreshAuth, 191 | })); 192 | 193 | const { getByTestId } = render(); 194 | 195 | await waitFor(() => { 196 | expect(getByTestId('token')).toHaveTextContent(initialToken); 197 | }); 198 | 199 | await act(async () => { 200 | getByTestId('refresh').click(); 201 | }); 202 | 203 | await waitFor(() => { 204 | expect(mockRefreshAuth).toHaveBeenCalledTimes(1); 205 | expect(getByTestId('error')).toHaveTextContent('Failed to refresh token'); 206 | }); 207 | }); 208 | 209 | it('should reset token state when user is undefined', async () => { 210 | const mockToken = 211 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 212 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(mockToken); 213 | 214 | // First render with user 215 | (useAuth as jest.Mock).mockImplementation(() => ({ 216 | user: { id: 'user_123' }, 217 | sessionId: 'session_123', 218 | refreshAuth: jest.fn().mockResolvedValue({}), 219 | })); 220 | 221 | const { getByTestId, rerender } = render(); 222 | 223 | await waitFor(() => { 224 | expect(getByTestId('token')).toHaveTextContent(mockToken); 225 | }); 226 | 227 | (useAuth as jest.Mock).mockImplementation(() => ({ 228 | user: undefined, 229 | sessionId: undefined, 230 | refreshAuth: jest.fn().mockResolvedValue({}), 231 | })); 232 | 233 | rerender(); 234 | 235 | await waitFor(() => { 236 | expect(getByTestId('token')).toHaveTextContent('no-token'); 237 | }); 238 | }); 239 | 240 | it('should handle invalid tokens gracefully', async () => { 241 | const invalidToken = 'invalid-token'; 242 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(invalidToken); 243 | 244 | const { getByTestId } = render(); 245 | 246 | await waitFor(() => { 247 | expect(getByTestId('loading')).toHaveTextContent('false'); 248 | expect(getByTestId('token')).toHaveTextContent('no-token'); 249 | }); 250 | }); 251 | 252 | it('should retry fetching when an error occurs', async () => { 253 | const error = new Error('Failed to fetch token'); 254 | const mockToken = 255 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 256 | 257 | (getAccessTokenAction as jest.Mock).mockRejectedValueOnce(error).mockResolvedValueOnce(mockToken); 258 | 259 | const { getByTestId } = render(); 260 | 261 | await waitFor(() => { 262 | expect(getByTestId('error')).toHaveTextContent('Failed to fetch token'); 263 | }); 264 | 265 | act(() => { 266 | jest.advanceTimersByTime(5 * 60 * 1000); // RETRY_DELAY 267 | }); 268 | 269 | await waitFor(() => { 270 | expect(getAccessTokenAction).toHaveBeenCalledTimes(2); 271 | expect(getByTestId('token')).toHaveTextContent(mockToken); 272 | expect(getByTestId('error')).toHaveTextContent('no-error'); 273 | }); 274 | }); 275 | 276 | it('should handle errors when refreshing an expiring token', async () => { 277 | // Create a token that's about to expire 278 | const currentTimeInSeconds = Math.floor(Date.now() / 1000); 279 | const payload = { sub: '1234567890', sid: 'session_123', exp: currentTimeInSeconds + 30 }; 280 | const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 281 | const error = new Error('Failed to refresh token'); 282 | 283 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(expiringToken); 284 | (refreshAccessTokenAction as jest.Mock).mockRejectedValueOnce(error); 285 | 286 | const { getByTestId } = render(); 287 | 288 | await waitFor(() => { 289 | expect(getByTestId('loading')).toHaveTextContent('false'); 290 | expect(getByTestId('error')).toHaveTextContent('Failed to refresh token'); 291 | }); 292 | 293 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 294 | expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); 295 | }); 296 | 297 | it('should handle token with an invalid payload format', async () => { 298 | const badPayloadToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalidpayload.mock-signature'; 299 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(badPayloadToken); 300 | 301 | const { getByTestId } = render(); 302 | 303 | await waitFor(() => { 304 | expect(getByTestId('loading')).toHaveTextContent('false'); 305 | expect(getByTestId('token')).toHaveTextContent('no-token'); 306 | }); 307 | }); 308 | 309 | it('should immediately try to update token when token is undefined', async () => { 310 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined); 311 | 312 | const { getByTestId } = render(); 313 | 314 | await waitFor(() => { 315 | expect(getByTestId('loading')).toHaveTextContent('false'); 316 | expect(getByTestId('token')).toHaveTextContent('no-token'); 317 | }); 318 | 319 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 320 | }); 321 | 322 | it('should react to sessionId changes', async () => { 323 | // Clear any previous mocks to ensure clean state 324 | jest.clearAllMocks(); 325 | 326 | const token1 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-1'; 327 | const token2 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2'; 328 | 329 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(token1).mockResolvedValueOnce(token2); 330 | 331 | (useAuth as jest.Mock).mockImplementation(() => ({ 332 | user: { id: 'user1' }, 333 | sessionId: 'session1', 334 | refreshAuth: jest.fn().mockResolvedValue({}), 335 | })); 336 | 337 | const { rerender } = render(); 338 | 339 | await waitFor(() => { 340 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 341 | }); 342 | 343 | (useAuth as jest.Mock).mockImplementation(() => ({ 344 | user: { id: 'user1' }, // Same user ID 345 | sessionId: 'session2', 346 | refreshAuth: jest.fn().mockResolvedValue({}), 347 | })); 348 | 349 | rerender(); 350 | 351 | await waitFor(() => { 352 | expect(getAccessTokenAction).toHaveBeenCalledTimes(2); 353 | }); 354 | }); 355 | 356 | it('should prevent concurrent token fetches via updateToken', async () => { 357 | jest.clearAllMocks(); 358 | 359 | const mockToken = 360 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 361 | 362 | let fetchCalls = 0; 363 | 364 | const tokenPromise = new Promise((resolve) => { 365 | setTimeout(() => { 366 | resolve(mockToken); 367 | }, 0); 368 | }); 369 | 370 | (getAccessTokenAction as jest.Mock).mockImplementation(() => { 371 | fetchCalls++; 372 | return tokenPromise; 373 | }); 374 | 375 | const { getByTestId } = render(); 376 | 377 | expect(getByTestId('loading')).toHaveTextContent('true'); 378 | 379 | await waitFor(() => { 380 | expect(fetchCalls).toBe(1); 381 | }); 382 | 383 | await waitFor(() => { 384 | expect(getByTestId('loading')).toHaveTextContent('false'); 385 | expect(getByTestId('token')).toHaveTextContent(mockToken); 386 | }); 387 | 388 | expect(fetchCalls).toBe(1); 389 | }); 390 | 391 | it('should prevent concurrent manual refresh operations', async () => { 392 | jest.clearAllMocks(); 393 | 394 | let refreshAuthCalls = 0; 395 | 396 | const mockToken = 397 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 398 | 399 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 400 | const refreshAuthPromise = new Promise((resolve) => { 401 | // Slow promise 402 | setTimeout(() => resolve({}), 10); 403 | }); 404 | 405 | const mockRefreshAuth = jest.fn().mockImplementation(() => { 406 | refreshAuthCalls++; 407 | return refreshAuthPromise; 408 | }); 409 | 410 | (useAuth as jest.Mock).mockImplementation(() => ({ 411 | user: { id: 'user_123' }, 412 | sessionId: 'session_123', 413 | refreshAuth: mockRefreshAuth, 414 | })); 415 | 416 | (getAccessTokenAction as jest.Mock).mockImplementation(() => { 417 | return Promise.resolve(mockToken); 418 | }); 419 | 420 | const { getByTestId } = render(); 421 | 422 | // Wait for initial token 423 | await waitFor(() => { 424 | expect(getByTestId('token')).toHaveTextContent(mockToken); 425 | }); 426 | 427 | // Call refresh twice in succession - should only result in one actual refresh call 428 | act(() => { 429 | getByTestId('refresh').click(); 430 | getByTestId('refresh').click(); 431 | }); 432 | 433 | // Wait for refresh to complete 434 | await waitFor(() => { 435 | expect(refreshAuthCalls).toBe(1); 436 | }); 437 | 438 | // Verify that refreshAuth was only called once despite two clicks 439 | expect(refreshAuthCalls).toBe(1); 440 | }); 441 | 442 | it('should handle non-Error objects thrown during token fetch', async () => { 443 | // Simulate a string error being thrown 444 | (getAccessTokenAction as jest.Mock).mockImplementation(() => { 445 | throw 'String error message'; 446 | }); 447 | 448 | const { getByTestId } = render(); 449 | 450 | await waitFor(() => { 451 | expect(getByTestId('loading')).toHaveTextContent('false'); 452 | expect(getByTestId('error')).toHaveTextContent('String error message'); 453 | }); 454 | }); 455 | 456 | // Additional test cases to increase coverage 457 | it('should handle concurrent manual refresh attempts', async () => { 458 | const initialToken = 459 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 460 | const refreshedToken = 461 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2'; 462 | 463 | // Setup a delayed promise for the refresh auth 464 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 465 | let resolveRefreshPromise: (value: any) => void; 466 | const refreshPromise = new Promise((resolve) => { 467 | resolveRefreshPromise = resolve; 468 | }); 469 | 470 | const mockRefreshAuth = jest.fn().mockReturnValue(refreshPromise); 471 | (useAuth as jest.Mock).mockImplementation(() => ({ 472 | user: { id: 'user_123' }, 473 | sessionId: 'session_123', 474 | refreshAuth: mockRefreshAuth, 475 | })); 476 | 477 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(initialToken).mockResolvedValueOnce(refreshedToken); 478 | 479 | const { getByTestId } = render(); 480 | 481 | await waitFor(() => { 482 | expect(getByTestId('token')).toHaveTextContent(initialToken); 483 | }); 484 | 485 | act(() => { 486 | getByTestId('refresh').click(); 487 | }); 488 | 489 | act(() => { 490 | getByTestId('refresh').click(); 491 | }); 492 | 493 | act(() => { 494 | resolveRefreshPromise!({}); 495 | }); 496 | 497 | await waitFor(() => { 498 | expect(mockRefreshAuth).toHaveBeenCalledTimes(1); // Should only call once 499 | expect(getAccessTokenAction).toHaveBeenCalledTimes(2); 500 | expect(getByTestId('token')).toHaveTextContent(refreshedToken); 501 | }); 502 | }); 503 | 504 | it('should handle errors during token refresh when autoRefresh is scheduled', async () => { 505 | // Create a token that expires in 2 minutes 506 | const currentTimeInSeconds = Math.floor(Date.now() / 1000); 507 | const expTimeInSeconds = currentTimeInSeconds + 120; // 2 minutes in future 508 | const payload = { sub: '1234567890', sid: 'session_123', exp: expTimeInSeconds }; 509 | const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 510 | 511 | const error = new Error('Failed to refresh token'); 512 | 513 | (getAccessTokenAction as jest.Mock) 514 | .mockResolvedValueOnce(token) // Initial fetch succeeds 515 | .mockRejectedValueOnce(error); // But auto-refresh fails 516 | 517 | render(); 518 | 519 | await waitFor(() => { 520 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 521 | }); 522 | 523 | act(() => { 524 | jest.advanceTimersByTime((120 - 60) * 1000); 525 | }); 526 | 527 | await waitFor(() => { 528 | expect(getAccessTokenAction).toHaveBeenCalledTimes(2); 529 | }); 530 | }); 531 | 532 | it('should clear refresh timeout on unmount', async () => { 533 | const mockToken = 534 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 535 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(mockToken); 536 | 537 | const { getByTestId, unmount } = render(); 538 | 539 | await waitFor(() => { 540 | expect(getByTestId('token')).toHaveTextContent(mockToken); 541 | }); 542 | 543 | unmount(); 544 | }); 545 | 546 | it('should handle edge cases when token data is null', async () => { 547 | // Create a token that resembles a JWT but with a null payload 548 | const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bnVsbA==.mock-signature'; // "null" in base64 549 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(token); 550 | 551 | const { getByTestId } = render(); 552 | 553 | await waitFor(() => { 554 | expect(getByTestId('loading')).toHaveTextContent('false'); 555 | }); 556 | 557 | expect(getByTestId('token')).toHaveTextContent('no-token'); 558 | }); 559 | 560 | it('should handle errors with string messages instead of Error objects', async () => { 561 | const error = 'String error message'; 562 | const errorObj = new Error(error); 563 | (getAccessTokenAction as jest.Mock).mockRejectedValueOnce(errorObj); 564 | 565 | const { getByTestId } = render(); 566 | 567 | await waitFor(() => { 568 | expect(getByTestId('loading')).toHaveTextContent('false'); 569 | expect(getByTestId('error')).toHaveTextContent(error); 570 | }); 571 | }); 572 | 573 | it('should handle string errors during manual refresh', async () => { 574 | const initialToken = 575 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 576 | const stringError = 'String error directly'; // Not wrapped in Error object 577 | 578 | (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(initialToken); 579 | 580 | // Mock refreshAuth to reject with a string, not an Error object 581 | const mockRefreshAuth = jest.fn().mockImplementation(() => { 582 | return Promise.reject(stringError); // Directly reject with string 583 | }); 584 | 585 | (useAuth as jest.Mock).mockImplementation(() => ({ 586 | user: { id: 'user_123' }, 587 | sessionId: 'session_123', 588 | refreshAuth: mockRefreshAuth, 589 | })); 590 | 591 | const { getByTestId } = render(); 592 | 593 | await waitFor(() => { 594 | expect(getByTestId('token')).toHaveTextContent(initialToken); 595 | }); 596 | 597 | act(() => { 598 | getByTestId('refresh').click(); 599 | }); 600 | 601 | await waitFor(() => { 602 | expect(mockRefreshAuth).toHaveBeenCalledTimes(1); 603 | expect(getByTestId('error')).toHaveTextContent(stringError); 604 | }); 605 | }); 606 | 607 | it('should bypass refresh when token is unchanged but user or sessionId changed', async () => { 608 | const token = 609 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature'; 610 | 611 | (getAccessTokenAction as jest.Mock).mockResolvedValue(token); 612 | 613 | (useAuth as jest.Mock).mockImplementation(() => ({ 614 | user: { id: 'user_123' }, 615 | sessionId: 'session_123', 616 | refreshAuth: jest.fn().mockResolvedValue({}), 617 | })); 618 | 619 | const { getByTestId, rerender } = render(); 620 | 621 | await waitFor(() => { 622 | expect(getByTestId('token')).toHaveTextContent(token); 623 | expect(getAccessTokenAction).toHaveBeenCalledTimes(1); 624 | }); 625 | 626 | (useAuth as jest.Mock).mockImplementation(() => ({ 627 | user: { id: 'user_456' }, // Different user 628 | sessionId: 'session_123', // Same session 629 | refreshAuth: jest.fn().mockResolvedValue({}), 630 | })); 631 | 632 | rerender(); 633 | 634 | await waitFor(() => { 635 | expect(getAccessTokenAction).toHaveBeenCalledTimes(2); 636 | }); 637 | }); 638 | }); 639 | -------------------------------------------------------------------------------- /__tests__/useTokenClaims.spec.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, waitFor } from '@testing-library/react'; 3 | import React from 'react'; 4 | import { useAuth } from '../src/components/authkit-provider.js'; 5 | 6 | jest.mock('../src/actions.js', () => ({ 7 | getAccessTokenAction: jest.fn(), 8 | refreshAccessTokenAction: jest.fn(), 9 | })); 10 | 11 | jest.mock('../src/components/authkit-provider.js', () => { 12 | const originalModule = jest.requireActual('../src/components/authkit-provider.js'); 13 | return { 14 | ...originalModule, 15 | useAuth: jest.fn(), 16 | }; 17 | }); 18 | 19 | jest.mock('../src/components/useAccessToken.js', () => ({ 20 | useAccessToken: jest.fn(() => ({ accessToken: undefined })), 21 | })); 22 | 23 | jest.mock('jose', () => ({ 24 | decodeJwt: jest.fn((token: string) => { 25 | if (token === 'malformed-token' || token === 'throw-error-token') { 26 | throw new Error('Invalid JWT'); 27 | } 28 | try { 29 | const parts = token.split('.'); 30 | if (parts.length !== 3) throw new Error('Invalid JWT'); 31 | const payload = JSON.parse(atob(parts[1])); 32 | return payload; 33 | } catch { 34 | throw new Error('Invalid JWT'); 35 | } 36 | }), 37 | })); 38 | 39 | // Import after mocks are set up 40 | import { useAccessToken } from '../src/components/useAccessToken.js'; 41 | import { useTokenClaims } from '../src/components/useTokenClaims.js'; 42 | 43 | describe('useTokenClaims', () => { 44 | beforeEach(() => { 45 | jest.clearAllMocks(); 46 | jest.useFakeTimers(); 47 | 48 | (useAuth as jest.Mock).mockImplementation(() => ({ 49 | user: { id: 'user_123' }, 50 | sessionId: 'session_123', 51 | refreshAuth: jest.fn().mockResolvedValue({}), 52 | })); 53 | 54 | // Reset useAccessToken mock to default 55 | (useAccessToken as jest.Mock).mockReturnValue({ accessToken: undefined }); 56 | }); 57 | 58 | afterEach(() => { 59 | jest.useRealTimers(); 60 | }); 61 | 62 | const TokenClaimsTestComponent = () => { 63 | const tokenClaims = useTokenClaims(); 64 | return ( 65 |
66 |
{JSON.stringify(tokenClaims)}
67 |
68 | ); 69 | }; 70 | 71 | it('should return empty object when no access token is available', async () => { 72 | (useAccessToken as jest.Mock).mockReturnValue({ accessToken: undefined }); 73 | 74 | const { getByTestId } = render(); 75 | 76 | await waitFor(() => { 77 | expect(getByTestId('claims')).toHaveTextContent('{}'); 78 | }); 79 | }); 80 | 81 | it('should return all token claims when access token is available', async () => { 82 | const payload = { 83 | aud: 'audience', 84 | exp: 9999999999, 85 | iat: 1234567800, 86 | iss: 'issuer', 87 | sub: 'user_123', 88 | sid: 'session_123', 89 | org_id: 'org_123', 90 | role: 'admin', 91 | permissions: ['read', 'write'], 92 | entitlements: ['feature_a'], 93 | jti: 'jwt_123', 94 | nbf: 1234567800, 95 | // Custom claims 96 | customField1: 'value1', 97 | customField2: 42, 98 | customObject: { nested: 'data' }, 99 | }; 100 | const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 101 | 102 | (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token }); 103 | 104 | const { getByTestId } = render(); 105 | 106 | await waitFor(() => { 107 | expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload)); 108 | }); 109 | }); 110 | 111 | it('should return all standard claims when token has only standard claims', async () => { 112 | const payload = { 113 | aud: 'audience', 114 | exp: 9999999999, 115 | iat: 1234567800, 116 | iss: 'issuer', 117 | sub: 'user_123', 118 | sid: 'session_123', 119 | org_id: 'org_123', 120 | role: 'admin', 121 | permissions: ['read', 'write'], 122 | entitlements: ['feature_a'], 123 | jti: 'jwt_123', 124 | nbf: 1234567800, 125 | }; 126 | const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 127 | 128 | (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token }); 129 | 130 | const { getByTestId } = render(); 131 | 132 | await waitFor(() => { 133 | expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload)); 134 | }); 135 | }); 136 | 137 | it('should handle partial claims', async () => { 138 | const payload = { 139 | sub: 'user_123', 140 | exp: 9999999999, 141 | customField: 'value', 142 | anotherCustom: true, 143 | }; 144 | const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 145 | 146 | (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token }); 147 | 148 | const { getByTestId } = render(); 149 | 150 | await waitFor(() => { 151 | expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload)); 152 | }); 153 | }); 154 | 155 | it('should handle complex nested claims', async () => { 156 | const payload = { 157 | sub: 'user_123', 158 | exp: 9999999999, 159 | metadata: { 160 | preferences: { 161 | theme: 'dark', 162 | language: 'en', 163 | }, 164 | settings: ['setting1', 'setting2'], 165 | }, 166 | tags: ['tag1', 'tag2'], 167 | permissions_custom: { 168 | read: true, 169 | write: false, 170 | }, 171 | }; 172 | const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`; 173 | 174 | (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token }); 175 | 176 | const { getByTestId } = render(); 177 | 178 | await waitFor(() => { 179 | expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload)); 180 | }); 181 | }); 182 | 183 | it('should return empty object when decodeJwt throws an error', async () => { 184 | (useAccessToken as jest.Mock).mockReturnValue({ accessToken: 'malformed-token' }); 185 | 186 | const { getByTestId } = render(); 187 | 188 | await waitFor(() => { 189 | expect(getByTestId('claims')).toHaveTextContent('{}'); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server.js'; 2 | import { redirectWithFallback, errorResponseWithFallback } from '../src/utils.js'; 3 | 4 | describe('utils', () => { 5 | afterEach(() => { 6 | jest.resetModules(); 7 | }); 8 | 9 | describe('redirectWithFallback', () => { 10 | it('uses NextResponse.redirect when available', () => { 11 | const redirectUrl = 'https://example.com'; 12 | const mockRedirect = jest.fn().mockReturnValue('redirected'); 13 | const originalRedirect = NextResponse.redirect; 14 | 15 | NextResponse.redirect = mockRedirect; 16 | 17 | const result = redirectWithFallback(redirectUrl); 18 | 19 | expect(mockRedirect).toHaveBeenCalledWith(redirectUrl, { headers: undefined }); 20 | expect(result).toBe('redirected'); 21 | 22 | NextResponse.redirect = originalRedirect; 23 | }); 24 | 25 | it('uses headers when provided', () => { 26 | const redirectUrl = 'https://example.com'; 27 | const headers = new Headers(); 28 | headers.set('Set-Cookie', 'test=1'); 29 | 30 | const result = redirectWithFallback(redirectUrl, headers); 31 | 32 | expect(result.headers.get('Set-Cookie')).toBe('test=1'); 33 | }); 34 | 35 | it('falls back to standard Response when NextResponse exists but redirect is undefined', async () => { 36 | const redirectUrl = 'https://example.com'; 37 | 38 | jest.resetModules(); 39 | 40 | jest.mock('next/server.js', () => ({ 41 | NextResponse: { 42 | // exists but has no redirect method 43 | }, 44 | })); 45 | 46 | const { redirectWithFallback } = await import('../src/utils.js'); 47 | 48 | const result = redirectWithFallback(redirectUrl); 49 | 50 | expect(result).toBeInstanceOf(Response); 51 | expect(result.status).toBe(307); 52 | expect(result.headers.get('Location')).toBe(redirectUrl); 53 | }); 54 | 55 | it('falls back to standard Response when NextResponse is undefined', async () => { 56 | const redirectUrl = 'https://example.com'; 57 | 58 | jest.resetModules(); 59 | 60 | // Mock with undefined NextResponse 61 | jest.mock('next/server.js', () => ({ 62 | NextResponse: undefined, 63 | })); 64 | 65 | const { redirectWithFallback } = await import('../src/utils.js'); 66 | 67 | const result = redirectWithFallback(redirectUrl); 68 | 69 | expect(result).toBeInstanceOf(Response); 70 | expect(result.status).toBe(307); 71 | expect(result.headers.get('Location')).toBe(redirectUrl); 72 | }); 73 | }); 74 | 75 | describe('errorResponseWithFallback', () => { 76 | const errorBody = { 77 | error: { 78 | message: 'Test error', 79 | description: 'Test description', 80 | }, 81 | }; 82 | 83 | it('uses NextResponse.json when available', () => { 84 | const mockJson = jest.fn().mockReturnValue('error json response'); 85 | NextResponse.json = mockJson; 86 | 87 | const result = errorResponseWithFallback(errorBody); 88 | 89 | expect(mockJson).toHaveBeenCalledWith(errorBody, { status: 500 }); 90 | expect(result).toBe('error json response'); 91 | }); 92 | 93 | it('falls back to standard Response when NextResponse is not available', () => { 94 | const originalJson = NextResponse.json; 95 | 96 | // @ts-expect-error - This is to test the fallback 97 | delete NextResponse.json; 98 | 99 | const result = errorResponseWithFallback(errorBody); 100 | 101 | expect(result).toBeInstanceOf(Response); 102 | expect(result.status).toBe(500); 103 | expect(result.headers.get('Content-Type')).toBe('application/json'); 104 | 105 | NextResponse.json = originalJson; 106 | }); 107 | 108 | it('falls back to standard Response when NextResponse exists but json is undefined', async () => { 109 | jest.resetModules(); 110 | 111 | jest.mock('next/server.js', () => ({ 112 | NextResponse: { 113 | // exists but has no json method 114 | }, 115 | })); 116 | 117 | const { errorResponseWithFallback } = await import('../src/utils.js'); 118 | 119 | const result = errorResponseWithFallback(errorBody); 120 | 121 | expect(result).toBeInstanceOf(Response); 122 | expect(result.status).toBe(500); 123 | expect(result.headers.get('Content-Type')).toBe('application/json'); 124 | }); 125 | 126 | it('falls back to standard Response when NextResponse is undefined', async () => { 127 | jest.resetModules(); 128 | 129 | jest.mock('next/server.js', () => ({ 130 | NextResponse: undefined, 131 | })); 132 | 133 | const { errorResponseWithFallback } = await import('../src/utils.js'); 134 | 135 | const result = errorResponseWithFallback(errorBody); 136 | 137 | expect(result).toBeInstanceOf(Response); 138 | expect(result.status).toBe(500); 139 | expect(result.headers.get('Content-Type')).toBe('application/json'); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /__tests__/workos.spec.ts: -------------------------------------------------------------------------------- 1 | import { WorkOS } from '@workos-inc/node'; 2 | import { getWorkOS, VERSION } from '../src/workos.js'; 3 | 4 | describe('workos', () => { 5 | const workos = getWorkOS(); 6 | beforeEach(() => { 7 | jest.clearAllMocks(); 8 | }); 9 | 10 | it('initializes WorkOS with the correct configuration', () => { 11 | // Extracting the config to avoid a circular dependency error 12 | const workosConfig = { 13 | apiHostname: workos.options.apiHostname, 14 | https: workos.options.https, 15 | port: workos.options.port, 16 | appInfo: workos.options.appInfo, 17 | }; 18 | 19 | expect(workosConfig).toEqual({ 20 | apiHostname: undefined, 21 | https: true, 22 | port: undefined, 23 | appInfo: { 24 | name: 'authkit/nextjs', 25 | version: VERSION, 26 | }, 27 | }); 28 | }); 29 | 30 | it('exports a WorkOS instance', () => { 31 | expect(workos).toBeInstanceOf(WorkOS); 32 | }); 33 | 34 | describe('with custom environment variables', () => { 35 | const originalEnv = process.env; 36 | 37 | beforeEach(() => { 38 | jest.resetModules(); 39 | process.env = { ...originalEnv }; 40 | }); 41 | 42 | afterEach(() => { 43 | process.env = originalEnv; 44 | }); 45 | 46 | it('uses custom API hostname when provided', async () => { 47 | process.env.WORKOS_API_HOSTNAME = 'custom.workos.com'; 48 | const { getWorkOS: customWorkos } = await import('../src/workos.js'); 49 | 50 | expect(customWorkos().options.apiHostname).toEqual('custom.workos.com'); 51 | }); 52 | 53 | it('uses custom HTTPS setting when provided', async () => { 54 | process.env.WORKOS_API_HTTPS = 'false'; 55 | const { getWorkOS: customWorkos } = await import('../src/workos.js'); 56 | 57 | expect(customWorkos().options.https).toEqual(false); 58 | }); 59 | 60 | it('uses custom port when provided', async () => { 61 | process.env.WORKOS_API_PORT = '8080'; 62 | const { getWorkOS: customWorkos } = await import('../src/workos.js'); 63 | 64 | expect(customWorkos().options.port).toEqual(8080); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | // Automatically clear mock calls, instances, contexts and results before every test 5 | clearMocks: true, 6 | 7 | // Indicates whether the coverage information should be collected while executing the test 8 | collectCoverage: true, 9 | 10 | // The directory where Jest should output its coverage files 11 | coverageDirectory: 'coverage', 12 | 13 | // Indicates which provider should be used to instrument code for coverage 14 | coverageProvider: 'babel', 15 | 16 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 17 | moduleNameMapper: { 18 | '^(\\.{1,2}/.*)\\.js$': '$1', // Handle ESM imports 19 | }, 20 | 21 | // A preset that is used as a base for Jest's configuration 22 | preset: 'ts-jest', 23 | 24 | // Run tests from one or more projects 25 | projects: [ 26 | { 27 | displayName: 'jsdom', 28 | testEnvironment: 'jsdom', 29 | testMatch: ['**/__tests__/**/*.spec.tsx'], 30 | transform: { 31 | '^.+\\.tsx?$': 'ts-jest', // Use ts-jest for TypeScript files 32 | }, 33 | moduleNameMapper: { 34 | '^(\\.{1,2}/.*)\\.js$': '$1', 35 | }, 36 | }, 37 | { 38 | displayName: 'node', 39 | testEnvironment: 'node', 40 | testMatch: ['**/__tests__/**/*.spec.ts'], 41 | transform: { 42 | '^.+\\.tsx?$': 'ts-jest', 43 | }, 44 | moduleNameMapper: { 45 | '^(\\.{1,2}/.*)\\.js$': '$1', 46 | }, 47 | setupFiles: ['/jest.setup.ts'], 48 | }, 49 | ], 50 | 51 | // Indicates whether each individual test should be reported during the run 52 | verbose: true, 53 | 54 | // Optionally, add these for better TypeScript support 55 | extensionsToTreatAsEsm: ['.ts'], 56 | 57 | coverageThreshold: { 58 | global: { 59 | branches: 100, 60 | functions: 100, 61 | lines: 100, 62 | statements: 100, 63 | }, 64 | }, 65 | }; 66 | 67 | export default config; 68 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | process.env.WORKOS_API_KEY = 'sk_test_1234567890'; 2 | process.env.WORKOS_CLIENT_ID = 'client_1234567890'; 3 | process.env.WORKOS_COOKIE_PASSWORD = 'kR620keEzOIzPThfnMEAba8XYgKdQ5vg'; 4 | process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI = 'http://localhost:3000/callback'; 5 | process.env.WORKOS_COOKIE_DOMAIN = 'example.com'; 6 | 7 | // Mock the next/headers module 8 | jest.mock('next/headers.js', () => { 9 | const cookieStore = new Map(); 10 | const headersStore = new Map(); 11 | 12 | return { 13 | headers: async () => ({ 14 | delete: jest.fn((name: string) => headersStore.delete(name)), 15 | get: jest.fn((name: string) => headersStore.get(name)), 16 | set: jest.fn((name: string, value: string) => headersStore.set(name, value)), 17 | _reset: () => { 18 | headersStore.clear(); 19 | }, 20 | }), 21 | cookies: async () => ({ 22 | delete: jest.fn((nameOrObject: string | { name: string; [key: string]: unknown }) => { 23 | const cookieName = typeof nameOrObject === 'string' ? nameOrObject : nameOrObject.name; 24 | cookieStore.delete(cookieName); 25 | }), 26 | get: jest.fn((name: string) => cookieStore.get(name)), 27 | getAll: jest.fn(() => Array.from(cookieStore.entries())), 28 | set: jest.fn((name: string, value: string | { [key: string]: string | number | boolean }) => 29 | cookieStore.set(name, { 30 | name, 31 | value, 32 | }), 33 | ), 34 | _reset: () => { 35 | cookieStore.clear(); 36 | }, 37 | }), 38 | }; 39 | }); 40 | 41 | jest.mock('next/navigation.js', () => ({ 42 | redirect: jest.fn(), 43 | })); 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@workos-inc/authkit-nextjs", 3 | "version": "2.3.3", 4 | "description": "Authentication and session helpers for using WorkOS & AuthKit with Next.js", 5 | "sideEffects": false, 6 | "type": "module", 7 | "main": "./dist/esm/index.js", 8 | "types": "./dist/esm/types/index.d.ts", 9 | "files": [ 10 | "dist", 11 | "src", 12 | "LICENSE", 13 | "README.md" 14 | ], 15 | "exports": { 16 | "./components": { 17 | "types": "./dist/esm/types/components/index.d.ts", 18 | "import": "./dist/esm/components/index.js" 19 | }, 20 | ".": { 21 | "types": "./dist/esm/types/index.d.ts", 22 | "import": "./dist/esm/index.js" 23 | } 24 | }, 25 | "scripts": { 26 | "clean": "rm -rf dist", 27 | "prebuild": "npm run clean", 28 | "build": "tsc --project tsconfig.json", 29 | "prepublishOnly": "npm run lint", 30 | "lint": "eslint \"src/**/*.ts*\"", 31 | "test": "jest", 32 | "test:watch": "jest --watch", 33 | "prettier": "prettier \"{src,__tests__}/**/*.{js,ts,tsx}\" --check", 34 | "format": "prettier \"{src,__tests__}/**/*.{js,ts,tsx}\" --write" 35 | }, 36 | "dependencies": { 37 | "@workos-inc/node": "^7.37.1", 38 | "iron-session": "^8.0.1", 39 | "jose": "^5.2.3", 40 | "path-to-regexp": "^6.2.2" 41 | }, 42 | "peerDependencies": { 43 | "next": "^13.5.9 || ^14.2.26 || ^15.2.3", 44 | "react": "^18.0 || ^19.0.0", 45 | "react-dom": "^18.0 || ^19.0.0" 46 | }, 47 | "devDependencies": { 48 | "@testing-library/jest-dom": "^6.6.3", 49 | "@testing-library/react": "^16.0.1", 50 | "@types/jest": "^29.5.14", 51 | "@types/node": "^20.11.28", 52 | "@types/react": "18.2.67", 53 | "@types/react-dom": "18.2.22", 54 | "@typescript-eslint/parser": "^7.7.1", 55 | "eslint": "^8.29.0", 56 | "eslint-config-prettier": "^9.1.0", 57 | "eslint-import-resolver-typescript": "^4.3.4", 58 | "eslint-plugin-import": "^2.31.0", 59 | "jest": "^29.7.0", 60 | "jest-environment-jsdom": "^29.7.0", 61 | "next": "^15.0.1", 62 | "prettier": "^3.3.3", 63 | "ts-jest": "^29.2.5", 64 | "ts-node": "^10.9.2", 65 | "typescript": "5.4.2", 66 | "typescript-eslint": "^7.2.0" 67 | }, 68 | "license": "MIT", 69 | "homepage": "https://github.com/workos/authkit-nextjs#readme", 70 | "repository": { 71 | "type": "git", 72 | "url": "git+https://github.com/workos/authkit-nextjs.git" 73 | }, 74 | "bugs": { 75 | "url": "https://github.com/workos/authkit-nextjs/issues" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { signOut, switchToOrganization } from './auth.js'; 4 | import { NoUserInfo, UserInfo, SwitchToOrganizationOptions } from './interfaces.js'; 5 | import { refreshSession, withAuth } from './session.js'; 6 | import { getWorkOS } from './workos.js'; 7 | 8 | /** 9 | * This function is used to sanitize the auth object. 10 | * Remove the accessToken from the auth object as it is not needed on the client side. 11 | * @param value - The auth object to sanitize 12 | * @returns The sanitized auth object 13 | */ 14 | function sanitize(value: T) { 15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 16 | const { accessToken, ...sanitized } = value; 17 | return sanitized; 18 | } 19 | 20 | /** 21 | * This action is only accessible to authenticated users, 22 | * there is no need to check the session here as the middleware will 23 | * be responsible for that. 24 | */ 25 | export const checkSessionAction = async () => { 26 | return true; 27 | }; 28 | 29 | export const handleSignOutAction = async ({ returnTo }: { returnTo?: string } = {}) => { 30 | await signOut({ returnTo }); 31 | }; 32 | 33 | export const getOrganizationAction = async (organizationId: string) => { 34 | return await getWorkOS().organizations.getOrganization(organizationId); 35 | }; 36 | 37 | export const getAuthAction = async (options?: { ensureSignedIn?: boolean }) => { 38 | return sanitize(await withAuth(options)); 39 | }; 40 | 41 | export const refreshAuthAction = async ({ 42 | ensureSignedIn, 43 | organizationId, 44 | }: { 45 | ensureSignedIn?: boolean; 46 | organizationId?: string; 47 | }) => { 48 | return sanitize(await refreshSession({ ensureSignedIn, organizationId })); 49 | }; 50 | 51 | export const switchToOrganizationAction = async (organizationId: string, options?: SwitchToOrganizationOptions) => { 52 | return sanitize(await switchToOrganization(organizationId, options)); 53 | }; 54 | 55 | /** 56 | * This action is used to get the access token from the auth object. 57 | * It is used to fetch the access token from the server. 58 | */ 59 | export async function getAccessTokenAction() { 60 | const auth = await withAuth(); 61 | return auth.accessToken; 62 | } 63 | 64 | /** 65 | * This action is used to refresh the access token from the auth object. 66 | * It is used to fetch the access token from the server. 67 | */ 68 | export async function refreshAccessTokenAction() { 69 | const auth = await refreshSession(); 70 | return auth.accessToken; 71 | } 72 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { revalidatePath, revalidateTag } from 'next/cache.js'; 4 | import { cookies, headers } from 'next/headers.js'; 5 | import { redirect } from 'next/navigation.js'; 6 | import { WORKOS_COOKIE_DOMAIN, WORKOS_COOKIE_NAME } from './env-variables.js'; 7 | import { getAuthorizationUrl } from './get-authorization-url.js'; 8 | import { SwitchToOrganizationOptions, UserInfo } from './interfaces.js'; 9 | import { refreshSession, withAuth } from './session.js'; 10 | import { getWorkOS } from './workos.js'; 11 | export async function getSignInUrl({ 12 | organizationId, 13 | loginHint, 14 | redirectUri, 15 | }: { organizationId?: string; loginHint?: string; redirectUri?: string } = {}) { 16 | return getAuthorizationUrl({ organizationId, screenHint: 'sign-in', loginHint, redirectUri }); 17 | } 18 | 19 | export async function getSignUpUrl({ 20 | organizationId, 21 | loginHint, 22 | redirectUri, 23 | }: { organizationId?: string; loginHint?: string; redirectUri?: string } = {}) { 24 | return getAuthorizationUrl({ organizationId, screenHint: 'sign-up', loginHint, redirectUri }); 25 | } 26 | 27 | /** 28 | * Sign out the user and delete the session cookie. 29 | * @param options Options for signing out. 30 | * @param options.returnTo The URL to redirect to after signing out. 31 | */ 32 | export async function signOut({ returnTo }: { returnTo?: string } = {}) { 33 | let sessionId: string | undefined; 34 | 35 | try { 36 | const { sessionId: sid } = await withAuth(); 37 | sessionId = sid; 38 | } finally { 39 | const nextCookies = await cookies(); 40 | const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; 41 | const domain = WORKOS_COOKIE_DOMAIN || /* istanbul ignore next */ undefined; 42 | nextCookies.delete({ name: cookieName, domain, path: '/' }); 43 | 44 | if (sessionId) { 45 | redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo })); 46 | } else { 47 | redirect(returnTo ?? '/'); 48 | } 49 | } 50 | } 51 | 52 | export async function switchToOrganization( 53 | organizationId: string, 54 | options: SwitchToOrganizationOptions = {}, 55 | ): Promise { 56 | const { returnTo, revalidationStrategy = 'path', revalidationTags = [] } = options; 57 | const headersList = await headers(); 58 | let result: UserInfo; 59 | // istanbul ignore next 60 | const pathname = returnTo || headersList.get('x-url') || '/'; 61 | try { 62 | result = await refreshSession({ organizationId, ensureSignedIn: true }); 63 | } catch ( 64 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 65 | error: any 66 | ) { 67 | const { cause } = error; 68 | /* istanbul ignore next */ 69 | if (cause?.rawData?.authkit_redirect_url) { 70 | redirect(cause.rawData.authkit_redirect_url); 71 | } else { 72 | if (cause?.error === 'sso_required' || cause?.error === 'mfa_enrollment') { 73 | const url = await getAuthorizationUrl({ organizationId }); 74 | return redirect(url); 75 | } 76 | throw error; 77 | } 78 | } 79 | 80 | switch (revalidationStrategy) { 81 | case 'path': 82 | revalidatePath(pathname); 83 | break; 84 | case 'tag': 85 | for (const tag of revalidationTags) { 86 | revalidateTag(tag); 87 | } 88 | break; 89 | } 90 | if (revalidationStrategy !== 'none') { 91 | redirect(pathname); 92 | } 93 | 94 | return result; 95 | } 96 | -------------------------------------------------------------------------------- /src/authkit-callback-route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from 'next/server.js'; 2 | import { WORKOS_CLIENT_ID } from './env-variables.js'; 3 | import { HandleAuthOptions } from './interfaces.js'; 4 | import { saveSession } from './session.js'; 5 | import { errorResponseWithFallback, redirectWithFallback } from './utils.js'; 6 | import { getWorkOS } from './workos.js'; 7 | 8 | export function handleAuth(options: HandleAuthOptions = {}) { 9 | const { returnPathname: returnPathnameOption = '/', baseURL, onSuccess, onError } = options; 10 | 11 | // Throw early if baseURL is provided but invalid 12 | if (baseURL) { 13 | try { 14 | new URL(baseURL); 15 | } catch (error) { 16 | throw new Error(`Invalid baseURL: ${baseURL}`, { cause: error }); 17 | } 18 | } 19 | 20 | return async function GET(request: NextRequest) { 21 | const code = request.nextUrl.searchParams.get('code'); 22 | const state = request.nextUrl.searchParams.get('state'); 23 | let returnPathname = state && state !== 'null' ? JSON.parse(atob(state)).returnPathname : null; 24 | 25 | if (code) { 26 | try { 27 | // Use the code returned to us by AuthKit and authenticate the user with WorkOS 28 | const { accessToken, refreshToken, user, impersonator, oauthTokens, authenticationMethod, organizationId } = 29 | await getWorkOS().userManagement.authenticateWithCode({ 30 | clientId: WORKOS_CLIENT_ID, 31 | code, 32 | }); 33 | 34 | // If baseURL is provided, use it instead of request.nextUrl 35 | // This is useful if the app is being run in a container like docker where 36 | // the hostname can be different from the one in the request 37 | const url = baseURL ? new URL(baseURL) : request.nextUrl.clone(); 38 | 39 | // Cleanup params 40 | url.searchParams.delete('code'); 41 | url.searchParams.delete('state'); 42 | 43 | // Redirect to the requested path and store the session 44 | returnPathname = returnPathname ?? returnPathnameOption; 45 | 46 | // Extract the search params if they are present 47 | if (returnPathname.includes('?')) { 48 | const newUrl = new URL(returnPathname, 'https://example.com'); 49 | url.pathname = newUrl.pathname; 50 | 51 | for (const [key, value] of newUrl.searchParams) { 52 | url.searchParams.append(key, value); 53 | } 54 | } else { 55 | url.pathname = returnPathname; 56 | } 57 | 58 | // Fall back to standard Response if NextResponse is not available. 59 | // This is to support Next.js 13. 60 | const response = redirectWithFallback(url.toString()); 61 | 62 | if (!accessToken || !refreshToken) throw new Error('response is missing tokens'); 63 | 64 | if (onSuccess) { 65 | await onSuccess({ 66 | accessToken, 67 | refreshToken, 68 | user, 69 | impersonator, 70 | oauthTokens, 71 | authenticationMethod, 72 | organizationId, 73 | }); 74 | } 75 | 76 | await saveSession({ accessToken, refreshToken, user, impersonator }, request); 77 | 78 | return response; 79 | } catch (error) { 80 | const errorRes = { 81 | error: error instanceof Error ? error.message : String(error), 82 | }; 83 | 84 | console.error(errorRes); 85 | 86 | return errorResponse(request, error); 87 | } 88 | } 89 | 90 | return errorResponse(request); 91 | }; 92 | 93 | function errorResponse(request: NextRequest, error?: unknown) { 94 | if (onError) { 95 | return onError({ error, request }); 96 | } 97 | 98 | return errorResponseWithFallback({ 99 | error: { 100 | message: 'Something went wrong', 101 | description: "Couldn't sign in. If you are not sure what happened, please contact your organization admin.", 102 | }, 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/components/authkit-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react'; 4 | import { 5 | checkSessionAction, 6 | getAuthAction, 7 | handleSignOutAction, 8 | refreshAuthAction, 9 | switchToOrganizationAction, 10 | } from '../actions.js'; 11 | import type { Impersonator, User } from '@workos-inc/node'; 12 | import type { UserInfo, SwitchToOrganizationOptions } from '../interfaces.js'; 13 | 14 | type AuthContextType = { 15 | user: User | null; 16 | sessionId: string | undefined; 17 | organizationId: string | undefined; 18 | role: string | undefined; 19 | permissions: string[] | undefined; 20 | entitlements: string[] | undefined; 21 | impersonator: Impersonator | undefined; 22 | loading: boolean; 23 | getAuth: (options?: { ensureSignedIn?: boolean }) => Promise; 24 | refreshAuth: (options?: { ensureSignedIn?: boolean; organizationId?: string }) => Promise; 25 | signOut: (options?: { returnTo?: string }) => Promise; 26 | switchToOrganization: ( 27 | organizationId: string, 28 | options?: SwitchToOrganizationOptions, 29 | ) => Promise | { error: string }>; 30 | }; 31 | 32 | const AuthContext = createContext(undefined); 33 | 34 | interface AuthKitProviderProps { 35 | children: ReactNode; 36 | /** 37 | * Customize what happens when a session is expired. By default,the entire page will be reloaded. 38 | * You can also pass this as `false` to disable the expired session checks. 39 | */ 40 | onSessionExpired?: false | (() => void); 41 | } 42 | 43 | export const AuthKitProvider = ({ children, onSessionExpired }: AuthKitProviderProps) => { 44 | const [user, setUser] = useState(null); 45 | const [sessionId, setSessionId] = useState(undefined); 46 | const [organizationId, setOrganizationId] = useState(undefined); 47 | const [role, setRole] = useState(undefined); 48 | const [permissions, setPermissions] = useState(undefined); 49 | const [entitlements, setEntitlements] = useState(undefined); 50 | const [impersonator, setImpersonator] = useState(undefined); 51 | const [loading, setLoading] = useState(true); 52 | 53 | const getAuth = async ({ ensureSignedIn = false }: { ensureSignedIn?: boolean } = {}) => { 54 | setLoading(true); 55 | try { 56 | const auth = await getAuthAction({ ensureSignedIn }); 57 | setUser(auth.user); 58 | setSessionId(auth.sessionId); 59 | setOrganizationId(auth.organizationId); 60 | setRole(auth.role); 61 | setPermissions(auth.permissions); 62 | setEntitlements(auth.entitlements); 63 | setImpersonator(auth.impersonator); 64 | } catch (error) { 65 | setUser(null); 66 | setSessionId(undefined); 67 | setOrganizationId(undefined); 68 | setRole(undefined); 69 | setPermissions(undefined); 70 | setEntitlements(undefined); 71 | setImpersonator(undefined); 72 | } finally { 73 | setLoading(false); 74 | } 75 | }; 76 | 77 | const switchToOrganization = async (organizationId: string, options: SwitchToOrganizationOptions = {}) => { 78 | const opts = { revalidationStrategy: 'none', ...options }; 79 | const result = await switchToOrganizationAction(organizationId, { 80 | revalidationStrategy: 'none', 81 | ...options, 82 | }); 83 | 84 | if (opts.revalidationStrategy === 'none') { 85 | await getAuth({ ensureSignedIn: true }); 86 | } 87 | 88 | return result; 89 | }; 90 | 91 | const refreshAuth = async ({ 92 | ensureSignedIn = false, 93 | organizationId, 94 | }: { ensureSignedIn?: boolean; organizationId?: string } = {}) => { 95 | try { 96 | setLoading(true); 97 | const auth = await refreshAuthAction({ ensureSignedIn, organizationId }); 98 | 99 | setUser(auth.user); 100 | setSessionId(auth.sessionId); 101 | setOrganizationId(auth.organizationId); 102 | setRole(auth.role); 103 | setPermissions(auth.permissions); 104 | setEntitlements(auth.entitlements); 105 | setImpersonator(auth.impersonator); 106 | } catch (error) { 107 | return error instanceof Error ? { error: error.message } : { error: String(error) }; 108 | } finally { 109 | setLoading(false); 110 | } 111 | }; 112 | 113 | const signOut = async ({ returnTo }: { returnTo?: string } = {}) => { 114 | await handleSignOutAction({ returnTo }); 115 | }; 116 | 117 | useEffect(() => { 118 | getAuth(); 119 | 120 | // Return early if the session expired checks are disabled. 121 | if (onSessionExpired === false) { 122 | return; 123 | } 124 | 125 | let visibilityChangedCalled = false; 126 | 127 | const handleVisibilityChange = async () => { 128 | if (visibilityChangedCalled) { 129 | return; 130 | } 131 | 132 | // In the case where we're using middleware auth mode, a user that has signed out in a different tab 133 | // will run into an issue if they attempt to hit a server action in the original tab. 134 | // This will force a refresh of the page in that case, which will redirect them to the sign-in page. 135 | if (document.visibilityState === 'visible') { 136 | visibilityChangedCalled = true; 137 | 138 | try { 139 | const hasSession = await checkSessionAction(); 140 | if (!hasSession) { 141 | throw new Error('Session expired'); 142 | } 143 | } catch (error) { 144 | // 'Failed to fetch' is the error we are looking for if the action fails 145 | // If any other error happens, for other reasons, we should not reload the page 146 | if (error instanceof Error && error.message.includes('Failed to fetch')) { 147 | if (onSessionExpired) { 148 | onSessionExpired(); 149 | } else { 150 | window.location.reload(); 151 | } 152 | } 153 | } finally { 154 | visibilityChangedCalled = false; 155 | } 156 | } 157 | }; 158 | 159 | window.addEventListener('visibilitychange', handleVisibilityChange); 160 | window.addEventListener('focus', handleVisibilityChange); 161 | 162 | return () => { 163 | window.removeEventListener('focus', handleVisibilityChange); 164 | window.removeEventListener('visibilitychange', handleVisibilityChange); 165 | }; 166 | }, [onSessionExpired]); 167 | 168 | return ( 169 | 185 | {children} 186 | 187 | ); 188 | }; 189 | 190 | export function useAuth(options: { 191 | ensureSignedIn: true; 192 | }): AuthContextType & ({ loading: true; user: User | null } | { loading: false; user: User }); 193 | export function useAuth(options?: { ensureSignedIn?: false }): AuthContextType; 194 | export function useAuth({ ensureSignedIn = false }: { ensureSignedIn?: boolean } = {}) { 195 | const context = useContext(AuthContext); 196 | 197 | useEffect(() => { 198 | if (context && ensureSignedIn && !context.user && !context.loading) { 199 | context.getAuth({ ensureSignedIn }); 200 | } 201 | }, [ensureSignedIn, context?.user, context?.loading, context?.getAuth]); 202 | 203 | if (!context) { 204 | throw new Error('useAuth must be used within an AuthKitProvider'); 205 | } 206 | 207 | return context; 208 | } 209 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const Button = React.forwardRef>((props, forwardedRef) => { 4 | return ( 5 | 135 | {side === 'top' ? '↗' : '↘'} 136 | 137 | 138 |
159 | {side === 'top' ? '↙' : '↖'} 160 |
161 | 162 | 163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { Impersonation } from './impersonation.js'; 2 | import { AuthKitProvider, useAuth } from './authkit-provider.js'; 3 | import { useAccessToken } from './useAccessToken.js'; 4 | import { useTokenClaims } from './useTokenClaims.js'; 5 | 6 | export { Impersonation, AuthKitProvider, useAuth, useAccessToken, useTokenClaims }; 7 | -------------------------------------------------------------------------------- /src/components/min-max-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { Button } from './button.js'; 5 | 6 | interface MinMaxButtonProps { 7 | children?: React.ReactNode; 8 | minimizedValue: '0' | '1'; 9 | } 10 | 11 | export function MinMaxButton({ children, minimizedValue }: MinMaxButtonProps) { 12 | return ( 13 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/useAccessToken.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useReducer, useRef } from 'react'; 2 | import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js'; 3 | import { useAuth } from './authkit-provider.js'; 4 | 5 | const TOKEN_EXPIRY_BUFFER_SECONDS = 60; 6 | const MIN_REFRESH_DELAY_SECONDS = 15; // minimum delay before refreshing token 7 | const RETRY_DELAY_SECONDS = 300; // 5 minutes 8 | 9 | interface TokenState { 10 | token: string | undefined; 11 | loading: boolean; 12 | error: Error | null; 13 | } 14 | 15 | type TokenAction = 16 | | { type: 'FETCH_START' } 17 | | { type: 'FETCH_SUCCESS'; token: string | undefined } 18 | | { type: 'FETCH_ERROR'; error: Error } 19 | | { type: 'RESET' }; 20 | 21 | function tokenReducer(state: TokenState, action: TokenAction): TokenState { 22 | switch (action.type) { 23 | case 'FETCH_START': 24 | return { ...state, loading: true, error: null }; 25 | case 'FETCH_SUCCESS': 26 | return { ...state, loading: false, token: action.token }; 27 | case 'FETCH_ERROR': 28 | return { ...state, loading: false, error: action.error }; 29 | case 'RESET': 30 | return { ...state, token: undefined, loading: false, error: null }; 31 | // istanbul ignore next 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | function getRefreshDelay(timeUntilExpiry: number) { 38 | return Math.max((timeUntilExpiry - TOKEN_EXPIRY_BUFFER_SECONDS) * 1000, MIN_REFRESH_DELAY_SECONDS * 1000); 39 | } 40 | 41 | function parseToken(token: string | undefined) { 42 | // istanbul ignore next 43 | if (!token) { 44 | return null; 45 | } 46 | 47 | try { 48 | const parts = token.split('.'); 49 | if (parts.length !== 3) { 50 | return null; 51 | } 52 | 53 | const payload = JSON.parse(atob(parts[1])); 54 | const now = Math.floor(Date.now() / 1000); 55 | 56 | return { 57 | payload, 58 | expiresAt: payload.exp, 59 | isExpiring: payload.exp < now + TOKEN_EXPIRY_BUFFER_SECONDS, 60 | timeUntilExpiry: payload.exp - now, 61 | }; 62 | } catch { 63 | // istanbul ignore next 64 | return null; 65 | } 66 | } 67 | 68 | /** 69 | * A hook that manages access tokens with automatic refresh. 70 | */ 71 | export function useAccessToken() { 72 | const { user, sessionId, refreshAuth } = useAuth(); 73 | const userId = user?.id; 74 | const [state, dispatch] = useReducer(tokenReducer, { 75 | token: undefined, 76 | loading: false, 77 | error: null, 78 | }); 79 | 80 | const refreshTimeoutRef = useRef>(); 81 | const fetchingRef = useRef(false); 82 | 83 | const clearRefreshTimeout = useCallback(() => { 84 | if (refreshTimeoutRef.current) { 85 | clearTimeout(refreshTimeoutRef.current); 86 | refreshTimeoutRef.current = undefined; 87 | } 88 | }, []); 89 | 90 | const updateToken = useCallback(async () => { 91 | if (fetchingRef.current) { 92 | return; 93 | } 94 | 95 | fetchingRef.current = true; 96 | dispatch({ type: 'FETCH_START' }); 97 | try { 98 | let token = await getAccessTokenAction(); 99 | if (token) { 100 | const tokenData = parseToken(token); 101 | if (!tokenData || tokenData.isExpiring) { 102 | token = await refreshAccessTokenAction(); 103 | } 104 | } 105 | 106 | dispatch({ type: 'FETCH_SUCCESS', token }); 107 | 108 | if (token) { 109 | const tokenData = parseToken(token); 110 | if (tokenData) { 111 | const delay = getRefreshDelay(tokenData.timeUntilExpiry); 112 | clearRefreshTimeout(); 113 | refreshTimeoutRef.current = setTimeout(updateToken, delay); 114 | } 115 | } 116 | 117 | return token; 118 | } catch (error) { 119 | dispatch({ type: 'FETCH_ERROR', error: error instanceof Error ? error : new Error(String(error)) }); 120 | refreshTimeoutRef.current = setTimeout(updateToken, RETRY_DELAY_SECONDS * 1000); 121 | } finally { 122 | fetchingRef.current = false; 123 | } 124 | }, [clearRefreshTimeout]); 125 | 126 | const refresh = useCallback(async () => { 127 | if (fetchingRef.current) { 128 | return; 129 | } 130 | 131 | fetchingRef.current = true; 132 | dispatch({ type: 'FETCH_START' }); 133 | 134 | try { 135 | await refreshAuth(); 136 | const token = await getAccessTokenAction(); 137 | 138 | dispatch({ type: 'FETCH_SUCCESS', token }); 139 | 140 | if (token) { 141 | const tokenData = parseToken(token); 142 | if (tokenData) { 143 | const delay = getRefreshDelay(tokenData.timeUntilExpiry); 144 | clearRefreshTimeout(); 145 | refreshTimeoutRef.current = setTimeout(updateToken, delay); 146 | } 147 | } 148 | 149 | return token; 150 | } catch (error) { 151 | const typedError = error instanceof Error ? error : new Error(String(error)); 152 | dispatch({ type: 'FETCH_ERROR', error: typedError }); 153 | refreshTimeoutRef.current = setTimeout(updateToken, RETRY_DELAY_SECONDS * 1000); 154 | } finally { 155 | fetchingRef.current = false; 156 | } 157 | }, [refreshAuth, clearRefreshTimeout, updateToken]); 158 | 159 | useEffect(() => { 160 | if (!user) { 161 | dispatch({ type: 'RESET' }); 162 | clearRefreshTimeout(); 163 | return; 164 | } 165 | updateToken(); 166 | 167 | return clearRefreshTimeout; 168 | }, [userId, sessionId, updateToken, clearRefreshTimeout]); 169 | 170 | return { 171 | accessToken: state.token, 172 | loading: state.loading, 173 | error: state.error, 174 | refresh, 175 | }; 176 | } 177 | -------------------------------------------------------------------------------- /src/components/useTokenClaims.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useAccessToken } from './useAccessToken.js'; 3 | import { decodeJwt, type JWTPayload } from 'jose'; 4 | 5 | type TokenClaims = Partial; 6 | 7 | /** 8 | * A hook that retrieves the claims from the access token. 9 | * 10 | * @example 11 | * ```ts 12 | * const {customClaim, iat } = useTokenClaims<{ customClaim: string }>(); 13 | * ``` 14 | * @returns The claims from the access token, or an empty object if the token is not available or cannot be parsed. 15 | */ 16 | export function useTokenClaims>(): TokenClaims { 17 | const { accessToken } = useAccessToken(); 18 | 19 | return useMemo(() => { 20 | if (!accessToken) { 21 | return {}; 22 | } 23 | 24 | try { 25 | return decodeJwt(accessToken); 26 | } catch { 27 | return {}; 28 | } 29 | }, [accessToken]); 30 | } 31 | -------------------------------------------------------------------------------- /src/cookie.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WORKOS_REDIRECT_URI, 3 | WORKOS_COOKIE_MAX_AGE, 4 | WORKOS_COOKIE_DOMAIN, 5 | WORKOS_COOKIE_SAMESITE, 6 | } from './env-variables.js'; 7 | import { CookieOptions } from './interfaces.js'; 8 | 9 | type ValidSameSite = CookieOptions['sameSite']; 10 | 11 | function assertValidSamSite(sameSite: string): asserts sameSite is ValidSameSite { 12 | if (!['lax', 'strict', 'none'].includes(sameSite.toLowerCase())) { 13 | throw new Error(`Invalid SameSite value: ${sameSite}`); 14 | } 15 | } 16 | 17 | export function getCookieOptions(): CookieOptions; 18 | export function getCookieOptions(redirectUri?: string | null): CookieOptions; 19 | export function getCookieOptions(redirectUri: string | null | undefined, asString: true, expired?: boolean): string; 20 | export function getCookieOptions( 21 | redirectUri: string | null | undefined, 22 | asString: false, 23 | expired?: boolean, 24 | ): CookieOptions; 25 | export function getCookieOptions( 26 | redirectUri?: string | null, 27 | asString?: boolean, 28 | expired?: boolean, 29 | ): CookieOptions | string; 30 | export function getCookieOptions( 31 | redirectUri?: string | null, 32 | asString: boolean = false, 33 | expired: boolean = false, 34 | ): CookieOptions | string { 35 | const url = new URL(redirectUri || WORKOS_REDIRECT_URI); 36 | const sameSite = WORKOS_COOKIE_SAMESITE || 'lax'; 37 | assertValidSamSite(sameSite); 38 | const secure = sameSite.toLowerCase() === 'none' ? true : url.protocol === 'https:'; 39 | 40 | const maxAge = expired ? 0 : WORKOS_COOKIE_MAX_AGE ? parseInt(WORKOS_COOKIE_MAX_AGE, 10) : 60 * 60 * 24 * 400; 41 | 42 | return asString 43 | ? `Path=/; HttpOnly; Secure=${secure}; SameSite=${sameSite}; Max-Age=${maxAge}; Domain=${WORKOS_COOKIE_DOMAIN || ''}` 44 | : { 45 | path: '/', 46 | httpOnly: true, 47 | secure, 48 | sameSite, 49 | // Defaults to 400 days, the maximum allowed by Chrome 50 | // It's fine to have a long cookie expiry date as the access/refresh tokens 51 | // act as the actual time-limited aspects of the session. 52 | maxAge, 53 | domain: WORKOS_COOKIE_DOMAIN || '', 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/env-variables.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | function getEnvVariable(name: string): string | undefined { 4 | return process.env[name]; 5 | } 6 | 7 | // Optional env variables 8 | const WORKOS_API_HOSTNAME = getEnvVariable('WORKOS_API_HOSTNAME'); 9 | const WORKOS_API_HTTPS = getEnvVariable('WORKOS_API_HTTPS'); 10 | const WORKOS_API_PORT = getEnvVariable('WORKOS_API_PORT'); 11 | const WORKOS_COOKIE_DOMAIN = getEnvVariable('WORKOS_COOKIE_DOMAIN'); 12 | const WORKOS_COOKIE_MAX_AGE = getEnvVariable('WORKOS_COOKIE_MAX_AGE'); 13 | const WORKOS_COOKIE_NAME = getEnvVariable('WORKOS_COOKIE_NAME'); 14 | const WORKOS_COOKIE_SAMESITE = getEnvVariable('WORKOS_COOKIE_SAMESITE') as 'lax' | 'strict' | 'none' | undefined; 15 | 16 | // Required env variables 17 | const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? ''; 18 | const WORKOS_CLIENT_ID = getEnvVariable('WORKOS_CLIENT_ID') ?? ''; 19 | const WORKOS_COOKIE_PASSWORD = getEnvVariable('WORKOS_COOKIE_PASSWORD') ?? ''; 20 | const WORKOS_REDIRECT_URI = process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI ?? ''; 21 | 22 | export { 23 | WORKOS_API_HOSTNAME, 24 | WORKOS_API_HTTPS, 25 | WORKOS_API_KEY, 26 | WORKOS_API_PORT, 27 | WORKOS_CLIENT_ID, 28 | WORKOS_COOKIE_DOMAIN, 29 | WORKOS_COOKIE_MAX_AGE, 30 | WORKOS_COOKIE_NAME, 31 | WORKOS_COOKIE_PASSWORD, 32 | WORKOS_REDIRECT_URI, 33 | WORKOS_COOKIE_SAMESITE, 34 | }; 35 | -------------------------------------------------------------------------------- /src/get-authorization-url.ts: -------------------------------------------------------------------------------- 1 | import { getWorkOS } from './workos.js'; 2 | import { WORKOS_CLIENT_ID, WORKOS_REDIRECT_URI } from './env-variables.js'; 3 | import { GetAuthURLOptions } from './interfaces.js'; 4 | import { headers } from 'next/headers.js'; 5 | 6 | async function getAuthorizationUrl(options: GetAuthURLOptions = {}) { 7 | const headersList = await headers(); 8 | const { 9 | returnPathname, 10 | screenHint, 11 | organizationId, 12 | redirectUri = headersList.get('x-redirect-uri'), 13 | loginHint, 14 | } = options; 15 | 16 | return getWorkOS().userManagement.getAuthorizationUrl({ 17 | provider: 'authkit', 18 | clientId: WORKOS_CLIENT_ID, 19 | redirectUri: redirectUri ?? WORKOS_REDIRECT_URI, 20 | state: returnPathname ? btoa(JSON.stringify({ returnPathname })) : undefined, 21 | screenHint, 22 | organizationId, 23 | loginHint, 24 | }); 25 | } 26 | 27 | export { getAuthorizationUrl }; 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js'; 2 | import { handleAuth } from './authkit-callback-route.js'; 3 | import { authkit, authkitMiddleware } from './middleware.js'; 4 | import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js'; 5 | import { getWorkOS } from './workos.js'; 6 | 7 | export * from './interfaces.js'; 8 | 9 | export { 10 | authkit, 11 | authkitMiddleware, 12 | getSignInUrl, 13 | getSignUpUrl, 14 | getWorkOS, 15 | handleAuth, 16 | refreshSession, 17 | saveSession, 18 | signOut, 19 | switchToOrganization, 20 | withAuth, 21 | getTokenClaims, 22 | }; 23 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node'; 2 | import { type NextRequest } from 'next/server.js'; 3 | 4 | export interface HandleAuthOptions { 5 | returnPathname?: string; 6 | baseURL?: string; 7 | onSuccess?: (data: HandleAuthSuccessData) => void | Promise; 8 | onError?: (params: { error?: unknown; request: NextRequest }) => Response | Promise; 9 | } 10 | 11 | export interface HandleAuthSuccessData extends Session { 12 | oauthTokens?: OauthTokens; 13 | organizationId?: string; 14 | authenticationMethod?: AuthenticationResponse['authenticationMethod']; 15 | } 16 | 17 | export interface Impersonator { 18 | email: string; 19 | reason: string | null; 20 | } 21 | export interface Session { 22 | accessToken: string; 23 | refreshToken: string; 24 | user: User; 25 | impersonator?: Impersonator; 26 | } 27 | 28 | export interface UserInfo { 29 | user: User; 30 | sessionId: string; 31 | organizationId?: string; 32 | role?: string; 33 | permissions?: string[]; 34 | entitlements?: string[]; 35 | impersonator?: Impersonator; 36 | accessToken: string; 37 | } 38 | export interface NoUserInfo { 39 | user: null; 40 | sessionId?: undefined; 41 | organizationId?: undefined; 42 | role?: undefined; 43 | permissions?: undefined; 44 | entitlements?: undefined; 45 | impersonator?: undefined; 46 | accessToken?: undefined; 47 | } 48 | 49 | export interface AccessToken { 50 | sid: string; 51 | org_id?: string; 52 | role?: string; 53 | permissions?: string[]; 54 | entitlements?: string[]; 55 | } 56 | 57 | export interface GetAuthURLOptions { 58 | screenHint?: 'sign-up' | 'sign-in'; 59 | returnPathname?: string; 60 | organizationId?: string; 61 | redirectUri?: string; 62 | loginHint?: string; 63 | } 64 | 65 | export interface AuthkitMiddlewareAuth { 66 | enabled: boolean; 67 | unauthenticatedPaths: string[]; 68 | } 69 | 70 | export interface AuthkitMiddlewareOptions { 71 | debug?: boolean; 72 | middlewareAuth?: AuthkitMiddlewareAuth; 73 | redirectUri?: string; 74 | signUpPaths?: string[]; 75 | } 76 | 77 | export interface AuthkitOptions { 78 | debug?: boolean; 79 | redirectUri?: string; 80 | screenHint?: 'sign-up' | 'sign-in'; 81 | onSessionRefreshSuccess?: (data: { 82 | accessToken: string; 83 | user: User; 84 | impersonator?: Impersonator; 85 | organizationId?: string; 86 | }) => void | Promise; 87 | onSessionRefreshError?: (params: { error?: unknown; request: NextRequest }) => void | Promise; 88 | } 89 | 90 | export interface AuthkitResponse { 91 | session: UserInfo | NoUserInfo; 92 | headers: Headers; 93 | authorizationUrl?: string; 94 | } 95 | 96 | export interface CookieOptions { 97 | path: '/'; 98 | httpOnly: true; 99 | secure: boolean; 100 | sameSite: 'lax' | 'strict' | 'none'; 101 | maxAge: number; 102 | domain: string | undefined; 103 | } 104 | 105 | export interface SwitchToOrganizationOptions { 106 | returnTo?: string; 107 | revalidationStrategy?: 'none' | 'tag' | 'path'; 108 | revalidationTags?: string[]; 109 | } 110 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextMiddleware, NextRequest } from 'next/server.js'; 2 | import { updateSessionMiddleware, updateSession } from './session.js'; 3 | import { AuthkitMiddlewareOptions, AuthkitOptions, AuthkitResponse } from './interfaces.js'; 4 | import { WORKOS_REDIRECT_URI } from './env-variables.js'; 5 | 6 | export function authkitMiddleware({ 7 | debug = false, 8 | middlewareAuth = { enabled: false, unauthenticatedPaths: [] }, 9 | redirectUri = WORKOS_REDIRECT_URI, 10 | signUpPaths = [], 11 | }: AuthkitMiddlewareOptions = {}): NextMiddleware { 12 | return function (request) { 13 | return updateSessionMiddleware(request, debug, middlewareAuth, redirectUri, signUpPaths); 14 | }; 15 | } 16 | 17 | export async function authkit(request: NextRequest, options: AuthkitOptions = {}): Promise { 18 | return await updateSession(request, options); 19 | } 20 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { sealData, unsealData } from 'iron-session'; 4 | import { JWTPayload, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; 5 | import { cookies, headers } from 'next/headers.js'; 6 | import { redirect } from 'next/navigation.js'; 7 | import { NextRequest, NextResponse } from 'next/server.js'; 8 | import { getCookieOptions } from './cookie.js'; 9 | import { WORKOS_CLIENT_ID, WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js'; 10 | import { getAuthorizationUrl } from './get-authorization-url.js'; 11 | import { 12 | AccessToken, 13 | AuthkitMiddlewareAuth, 14 | AuthkitOptions, 15 | AuthkitResponse, 16 | NoUserInfo, 17 | Session, 18 | UserInfo, 19 | } from './interfaces.js'; 20 | import { getWorkOS } from './workos.js'; 21 | 22 | import type { AuthenticationResponse } from '@workos-inc/node'; 23 | import { parse, tokensToRegexp } from 'path-to-regexp'; 24 | import { lazy, redirectWithFallback } from './utils.js'; 25 | 26 | const sessionHeaderName = 'x-workos-session'; 27 | const middlewareHeaderName = 'x-workos-middleware'; 28 | const signUpPathsHeaderName = 'x-sign-up-paths'; 29 | 30 | const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(WORKOS_CLIENT_ID)))); 31 | 32 | async function encryptSession(session: Session) { 33 | return sealData(session, { 34 | password: WORKOS_COOKIE_PASSWORD, 35 | ttl: 0, 36 | }); 37 | } 38 | 39 | async function updateSessionMiddleware( 40 | request: NextRequest, 41 | debug: boolean, 42 | middlewareAuth: AuthkitMiddlewareAuth, 43 | redirectUri: string, 44 | signUpPaths: string[], 45 | ) { 46 | if (!redirectUri && !WORKOS_REDIRECT_URI) { 47 | throw new Error('You must provide a redirect URI in the AuthKit middleware or in the environment variables.'); 48 | } 49 | 50 | if (!WORKOS_COOKIE_PASSWORD || WORKOS_COOKIE_PASSWORD.length < 32) { 51 | throw new Error( 52 | 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', 53 | ); 54 | } 55 | 56 | let url; 57 | 58 | if (redirectUri) { 59 | url = new URL(redirectUri); 60 | } else { 61 | url = new URL(WORKOS_REDIRECT_URI); 62 | } 63 | 64 | if ( 65 | middlewareAuth.enabled && 66 | url.pathname === request.nextUrl.pathname && 67 | !middlewareAuth.unauthenticatedPaths.includes(url.pathname) 68 | ) { 69 | // In the case where: 70 | // - We're using middleware auth mode 71 | // - The redirect URI is in the middleware matcher 72 | // - The redirect URI isn't in the unauthenticatedPaths array 73 | // 74 | // then we would get stuck in a login loop due to the redirect happening before the session is set. 75 | // It's likely that the user accidentally forgot to add the path to unauthenticatedPaths, so we add it here. 76 | middlewareAuth.unauthenticatedPaths.push(url.pathname); 77 | } 78 | 79 | const matchedPaths: string[] = middlewareAuth.unauthenticatedPaths.filter((pathGlob) => { 80 | const pathRegex = getMiddlewareAuthPathRegex(pathGlob); 81 | 82 | return pathRegex.exec(request.nextUrl.pathname); 83 | }); 84 | 85 | const { session, headers, authorizationUrl } = await updateSession(request, { 86 | debug, 87 | redirectUri, 88 | screenHint: getScreenHint(signUpPaths, request.nextUrl.pathname), 89 | }); 90 | 91 | // If the user is logged out and this path isn't on the allowlist for logged out paths, redirect to AuthKit. 92 | if (middlewareAuth.enabled && matchedPaths.length === 0 && !session.user) { 93 | if (debug) { 94 | console.log(`Unauthenticated user on protected route ${request.url}, redirecting to AuthKit`); 95 | } 96 | 97 | return redirectWithFallback(authorizationUrl as string, headers); 98 | } 99 | 100 | // Record the sign up paths so we can use them later 101 | if (signUpPaths.length > 0) { 102 | headers.set(signUpPathsHeaderName, signUpPaths.join(',')); 103 | } 104 | 105 | return NextResponse.next({ 106 | headers, 107 | }); 108 | } 109 | 110 | async function updateSession( 111 | request: NextRequest, 112 | options: AuthkitOptions = { debug: false }, 113 | ): Promise { 114 | const session = await getSessionFromCookie(request); 115 | 116 | // Since we're setting the headers in the response, we need to create a new Headers object without copying 117 | // the request headers. 118 | // See https://github.com/vercel/next.js/issues/50659#issuecomment-2333990159 119 | const newRequestHeaders = new Headers(); 120 | 121 | // Record that the request was routed through the middleware so we can check later for DX purposes 122 | newRequestHeaders.set(middlewareHeaderName, 'true'); 123 | 124 | // We store the current request url in a custom header, so we can always have access to it 125 | // This is because on hard navigations we don't have access to `next-url` but need to get the current 126 | // `pathname` to be able to return the users where they came from before sign-in 127 | newRequestHeaders.set('x-url', request.url); 128 | 129 | if (options.redirectUri) { 130 | // Store the redirect URI in a custom header, so we always have access to it and so that subsequent 131 | // calls to `getAuthorizationUrl` will use the same redirect URI 132 | newRequestHeaders.set('x-redirect-uri', options.redirectUri); 133 | } 134 | 135 | newRequestHeaders.delete(sessionHeaderName); 136 | 137 | if (!session) { 138 | if (options.debug) { 139 | console.log('No session found from cookie'); 140 | } 141 | 142 | return { 143 | session: { user: null }, 144 | headers: newRequestHeaders, 145 | authorizationUrl: await getAuthorizationUrl({ 146 | returnPathname: getReturnPathname(request.url), 147 | redirectUri: options.redirectUri || WORKOS_REDIRECT_URI, 148 | screenHint: options.screenHint, 149 | }), 150 | }; 151 | } 152 | 153 | const hasValidSession = await verifyAccessToken(session.accessToken); 154 | 155 | const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; 156 | 157 | if (hasValidSession) { 158 | newRequestHeaders.set(sessionHeaderName, request.cookies.get(cookieName)!.value); 159 | 160 | const { 161 | sid: sessionId, 162 | org_id: organizationId, 163 | role, 164 | permissions, 165 | entitlements, 166 | } = decodeJwt(session.accessToken); 167 | 168 | return { 169 | session: { 170 | sessionId, 171 | user: session.user, 172 | organizationId, 173 | role, 174 | permissions, 175 | entitlements, 176 | impersonator: session.impersonator, 177 | accessToken: session.accessToken, 178 | }, 179 | headers: newRequestHeaders, 180 | }; 181 | } 182 | 183 | try { 184 | if (options.debug) { 185 | // istanbul ignore next 186 | console.log( 187 | `Session invalid. ${session.accessToken ? `Refreshing access token that ends in ${session.accessToken.slice(-10)}` : 'Access token missing.'}`, 188 | ); 189 | } 190 | 191 | const { org_id: organizationIdFromAccessToken } = decodeJwt(session.accessToken); 192 | 193 | const { accessToken, refreshToken, user, impersonator } = 194 | await getWorkOS().userManagement.authenticateWithRefreshToken({ 195 | clientId: WORKOS_CLIENT_ID, 196 | refreshToken: session.refreshToken, 197 | organizationId: organizationIdFromAccessToken, 198 | }); 199 | 200 | if (options.debug) { 201 | console.log('Session successfully refreshed'); 202 | } 203 | // Encrypt session with new access and refresh tokens 204 | const encryptedSession = await encryptSession({ 205 | accessToken, 206 | refreshToken, 207 | user, 208 | impersonator, 209 | }); 210 | 211 | newRequestHeaders.append('Set-Cookie', `${cookieName}=${encryptedSession}; ${getCookieOptions(request.url, true)}`); 212 | newRequestHeaders.set(sessionHeaderName, encryptedSession); 213 | 214 | const { 215 | sid: sessionId, 216 | org_id: organizationId, 217 | role, 218 | permissions, 219 | entitlements, 220 | } = decodeJwt(accessToken); 221 | 222 | options.onSessionRefreshSuccess?.({ accessToken, user, impersonator, organizationId }); 223 | 224 | return { 225 | session: { 226 | sessionId, 227 | user, 228 | organizationId, 229 | role, 230 | permissions, 231 | entitlements, 232 | impersonator, 233 | accessToken, 234 | }, 235 | headers: newRequestHeaders, 236 | }; 237 | } catch (e) { 238 | if (options.debug) { 239 | console.log('Failed to refresh. Deleting cookie.', e); 240 | } 241 | 242 | // When we need to delete a cookie, return it as a header as you can't delete cookies from edge middleware 243 | const deleteCookie = `${cookieName}=; Expires=${new Date(0).toUTCString()}; ${getCookieOptions(request.url, true, true)}`; 244 | newRequestHeaders.append('Set-Cookie', deleteCookie); 245 | 246 | options.onSessionRefreshError?.({ error: e, request }); 247 | 248 | return { 249 | session: { user: null }, 250 | headers: newRequestHeaders, 251 | authorizationUrl: await getAuthorizationUrl({ 252 | returnPathname: getReturnPathname(request.url), 253 | redirectUri: options.redirectUri || WORKOS_REDIRECT_URI, 254 | }), 255 | }; 256 | } 257 | } 258 | 259 | async function refreshSession(options: { organizationId?: string; ensureSignedIn: true }): Promise; 260 | async function refreshSession(options?: { 261 | organizationId?: string; 262 | ensureSignedIn?: boolean; 263 | }): Promise; 264 | async function refreshSession({ 265 | organizationId: nextOrganizationId, 266 | ensureSignedIn = false, 267 | }: { 268 | organizationId?: string; 269 | ensureSignedIn?: boolean; 270 | } = {}): Promise { 271 | const session = await getSessionFromCookie(); 272 | if (!session) { 273 | if (ensureSignedIn) { 274 | await redirectToSignIn(); 275 | } 276 | return { user: null }; 277 | } 278 | 279 | const { org_id: organizationIdFromAccessToken } = decodeJwt(session.accessToken); 280 | 281 | let refreshResult; 282 | 283 | try { 284 | refreshResult = await getWorkOS().userManagement.authenticateWithRefreshToken({ 285 | clientId: WORKOS_CLIENT_ID, 286 | refreshToken: session.refreshToken, 287 | organizationId: nextOrganizationId ?? organizationIdFromAccessToken, 288 | }); 289 | } catch (error) { 290 | throw new Error(`Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`, { 291 | cause: error, 292 | }); 293 | } 294 | 295 | const headersList = await headers(); 296 | const url = headersList.get('x-url'); 297 | 298 | await saveSession(refreshResult, url || WORKOS_REDIRECT_URI); 299 | 300 | const { accessToken, user, impersonator } = refreshResult; 301 | 302 | const { 303 | sid: sessionId, 304 | org_id: organizationId, 305 | role, 306 | permissions, 307 | entitlements, 308 | } = decodeJwt(accessToken); 309 | 310 | return { 311 | sessionId, 312 | user, 313 | organizationId, 314 | role, 315 | permissions, 316 | entitlements, 317 | impersonator, 318 | accessToken, 319 | }; 320 | } 321 | 322 | function getMiddlewareAuthPathRegex(pathGlob: string) { 323 | try { 324 | const url = new URL(pathGlob, 'https://example.com'); 325 | const path = `${url.pathname!}${url.hash || ''}`; 326 | 327 | const tokens = parse(path); 328 | const regex = tokensToRegexp(tokens).source; 329 | 330 | return new RegExp(regex); 331 | } catch (err) { 332 | console.log('err', err); 333 | const message = err instanceof Error ? err.message : String(err); 334 | 335 | throw new Error(`Error parsing routes for middleware auth. Reason: ${message}`); 336 | } 337 | } 338 | 339 | async function redirectToSignIn() { 340 | const headersList = await headers(); 341 | const url = headersList.get('x-url'); 342 | 343 | if (!url) { 344 | throw new Error('No URL found in the headers'); 345 | } 346 | 347 | // Determine if the current route is in the sign up paths 348 | const signUpPaths = headersList.get(signUpPathsHeaderName)?.split(','); 349 | 350 | const pathname = new URL(url).pathname; 351 | const screenHint = getScreenHint(signUpPaths, pathname); 352 | 353 | const returnPathname = getReturnPathname(url); 354 | 355 | redirect(await getAuthorizationUrl({ returnPathname, screenHint })); 356 | } 357 | 358 | export async function getTokenClaims>( 359 | accessToken?: string, 360 | ): Promise> { 361 | const token = accessToken ?? (await withAuth()).accessToken; 362 | if (!token) { 363 | return {}; 364 | } 365 | 366 | return decodeJwt(token); 367 | } 368 | 369 | async function withAuth(options: { ensureSignedIn: true }): Promise; 370 | async function withAuth(options?: { ensureSignedIn?: true | false }): Promise; 371 | async function withAuth(options?: { ensureSignedIn?: boolean }): Promise { 372 | const session = await getSessionFromHeader(); 373 | 374 | if (!session) { 375 | if (options?.ensureSignedIn) { 376 | await redirectToSignIn(); 377 | } 378 | return { user: null }; 379 | } 380 | 381 | const { 382 | sid: sessionId, 383 | org_id: organizationId, 384 | role, 385 | permissions, 386 | entitlements, 387 | } = decodeJwt(session.accessToken); 388 | 389 | return { 390 | sessionId, 391 | user: session.user, 392 | organizationId, 393 | role, 394 | permissions, 395 | entitlements, 396 | impersonator: session.impersonator, 397 | accessToken: session.accessToken, 398 | }; 399 | } 400 | 401 | async function verifyAccessToken(accessToken: string) { 402 | try { 403 | await jwtVerify(accessToken, JWKS()); 404 | return true; 405 | } catch { 406 | return false; 407 | } 408 | } 409 | 410 | async function getSessionFromCookie(request?: NextRequest) { 411 | const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; 412 | let cookie; 413 | 414 | if (request) { 415 | cookie = request.cookies.get(cookieName); 416 | } else { 417 | const nextCookies = await cookies(); 418 | cookie = nextCookies.get(cookieName); 419 | } 420 | 421 | if (cookie) { 422 | return unsealData(cookie.value, { 423 | password: WORKOS_COOKIE_PASSWORD, 424 | }); 425 | } 426 | } 427 | 428 | async function getSessionFromHeader(): Promise { 429 | const headersList = await headers(); 430 | const hasMiddleware = Boolean(headersList.get(middlewareHeaderName)); 431 | 432 | if (!hasMiddleware) { 433 | const url = headersList.get('x-url'); 434 | throw new Error( 435 | `You are calling 'withAuth' on ${url ?? 'a route'} that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'.`, 436 | ); 437 | } 438 | 439 | const authHeader = headersList.get(sessionHeaderName); 440 | if (!authHeader) return; 441 | 442 | return unsealData(authHeader, { password: WORKOS_COOKIE_PASSWORD }); 443 | } 444 | 445 | function getReturnPathname(url: string): string { 446 | const newUrl = new URL(url); 447 | 448 | return `${newUrl.pathname}${newUrl.searchParams.size > 0 ? '?' + newUrl.searchParams.toString() : ''}`; 449 | } 450 | 451 | function getScreenHint(signUpPaths: string[] | undefined, pathname: string) { 452 | if (!signUpPaths) return 'sign-in'; 453 | 454 | const screenHintPaths: string[] = signUpPaths.filter((pathGlob) => { 455 | const pathRegex = getMiddlewareAuthPathRegex(pathGlob); 456 | return pathRegex.exec(pathname); 457 | }); 458 | 459 | return screenHintPaths.length > 0 ? 'sign-up' : 'sign-in'; 460 | } 461 | 462 | /** 463 | * Saves a WorkOS session to a cookie for use with AuthKit. 464 | * 465 | * This function is intended for advanced use cases where you need to manually manage sessions, 466 | * such as custom authentication flows (email verification, etc.) that don't use 467 | * the standard AuthKit authentication flow. 468 | * 469 | * @param sessionOrResponse The WorkOS session or AuthenticationResponse containing access token, refresh token, and user information. 470 | * @param request Either a NextRequest object or a URL string, used to determine cookie settings. 471 | * 472 | * @example 473 | * // With a NextRequest object 474 | * import { saveSession } from '@workos-inc/authkit-nextjs'; 475 | * 476 | * async function handleEmailVerification(req: NextRequest) { 477 | * const { code } = await req.json(); 478 | * const authResponse = await workos.userManagement.authenticateWithEmailVerification({ 479 | * clientId: process.env.WORKOS_CLIENT_ID, 480 | * code, 481 | * }); 482 | * 483 | * await saveSession(authResponse, req); 484 | * } 485 | * 486 | * @example 487 | * // With a URL string 488 | * await saveSession(authResponse, 'https://example.com/callback'); 489 | */ 490 | export async function saveSession( 491 | sessionOrResponse: Session | AuthenticationResponse, 492 | request: NextRequest | string, 493 | ): Promise { 494 | const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; 495 | const encryptedSession = await encryptSession(sessionOrResponse); 496 | const nextCookies = await cookies(); 497 | const url = typeof request === 'string' ? request : request.url; 498 | nextCookies.set(cookieName, encryptedSession, getCookieOptions(url)); 499 | } 500 | 501 | export { encryptSession, refreshSession, updateSession, updateSessionMiddleware, withAuth }; 502 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server.js'; 2 | 3 | export function redirectWithFallback(redirectUri: string, headers?: Headers) { 4 | const newHeaders = headers ? new Headers(headers) : new Headers(); 5 | newHeaders.set('Location', redirectUri); 6 | 7 | // Fall back to standard Response if NextResponse is not available. 8 | // This is to support Next.js 13. 9 | return NextResponse?.redirect 10 | ? NextResponse.redirect(redirectUri, { headers }) 11 | : new Response(null, { status: 307, headers: newHeaders }); 12 | } 13 | 14 | export function errorResponseWithFallback(errorBody: { error: { message: string; description: string } }) { 15 | // Fall back to standard Response if NextResponse is not available. 16 | // This is to support Next.js 13. 17 | return NextResponse?.json 18 | ? NextResponse.json(errorBody, { status: 500 }) 19 | : new Response(JSON.stringify(errorBody), { 20 | status: 500, 21 | headers: { 'Content-Type': 'application/json' }, 22 | }); 23 | } 24 | 25 | /** 26 | * Returns a function that can only be called once. 27 | * Subsequent calls will return the result of the first call. 28 | * This is useful for lazy initialization. 29 | * @param fn - The function to be called once. 30 | * @returns A function that can only be called once. 31 | */ 32 | export function lazy(fn: () => T): () => T { 33 | let called = false; 34 | let result: T; 35 | return () => { 36 | if (!called) { 37 | result = fn(); 38 | called = true; 39 | } 40 | return result; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/workos.ts: -------------------------------------------------------------------------------- 1 | import { WorkOS } from '@workos-inc/node'; 2 | import { WORKOS_API_HOSTNAME, WORKOS_API_KEY, WORKOS_API_HTTPS, WORKOS_API_PORT } from './env-variables.js'; 3 | import { lazy } from './utils.js'; 4 | 5 | export const VERSION = '2.3.3'; 6 | 7 | const options = { 8 | apiHostname: WORKOS_API_HOSTNAME, 9 | https: WORKOS_API_HTTPS ? WORKOS_API_HTTPS === 'true' : true, 10 | port: WORKOS_API_PORT ? parseInt(WORKOS_API_PORT) : undefined, 11 | appInfo: { 12 | name: 'authkit/nextjs', 13 | version: VERSION, 14 | }, 15 | }; 16 | 17 | /** 18 | * Create a WorkOS instance with the provided API key and options. 19 | * If an instance already exists, it returns the existing instance. 20 | * @returns The WorkOS instance. 21 | */ 22 | export const getWorkOS = lazy(() => new WorkOS(WORKOS_API_KEY, options)); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "lib": ["DOM", "ESNext", "DOM.Iterable"], 5 | "jsx": "react", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "importHelpers": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "alwaysStrict": true, 13 | "skipLibCheck": true, 14 | "outDir": "./dist/esm", 15 | "declarationDir": "./dist/esm/types", 16 | "module": "ES2020", 17 | "moduleResolution": "node", 18 | "allowSyntheticDefaultImports": true 19 | }, 20 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "jest.config.ts", "jest.setup.ts", "/dist/**/*", "__tests__/**/*"] 21 | } 22 | 23 | -------------------------------------------------------------------------------- /types/react.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import * as React from 'react'; 3 | 4 | declare module 'react' { 5 | interface CSSProperties { 6 | [key: `--${string}`]: unknown; 7 | } 8 | } 9 | 10 | export {}; 11 | --------------------------------------------------------------------------------