From 2261ef9321a0e3974456af2db11915a128d69421 Mon Sep 17 00:00:00 2001 From: Amadeusz Winogrodzki Date: Sun, 14 Jul 2024 18:28:45 +0200 Subject: [PATCH] feat: added `getValidCustomToken` method and documented client-side SDK usage --- docs/pages/docs/usage/_meta.json | 1 + docs/pages/docs/usage/client-side-apis.mdx | 134 ++++++++++++++++++ docs/pages/docs/usage/index.mdx | 5 + docs/pages/docs/usage/middleware.mdx | 4 +- docs/pages/docs/usage/server-components.mdx | 23 +-- .../next-typescript-minimal/middleware.ts | 2 +- examples/next-typescript-starter/README.md | 21 +++ examples/next-typescript-starter/api/index.ts | 4 +- .../route.ts | 0 .../app/auth/AuthContext.ts | 2 + .../app/profile/UserProfile/UserProfile.tsx | 51 +++++-- .../app/profile/UserProfile/user-counters.ts | 35 +++++ .../app/shared/user.ts | 6 +- .../next-typescript-starter/middleware.ts | 6 +- src/next/client.ts | 73 ++++++++-- src/next/refresh-token.ts | 3 +- 16 files changed, 323 insertions(+), 47 deletions(-) create mode 100644 docs/pages/docs/usage/client-side-apis.mdx rename examples/next-typescript-starter/app/api/{refresh-token => check-email-verification}/route.ts (100%) create mode 100644 examples/next-typescript-starter/app/profile/UserProfile/user-counters.ts diff --git a/docs/pages/docs/usage/_meta.json b/docs/pages/docs/usage/_meta.json index 2055c503..36dce716 100644 --- a/docs/pages/docs/usage/_meta.json +++ b/docs/pages/docs/usage/_meta.json @@ -6,6 +6,7 @@ "pages-router-api-routes": "Pages Router API Routes", "get-server-side-props": "Usage in getServerSideProps", "refresh-credentials": "Refreshing credentials", + "client-side-apis": "Using Client-Side APIs", "domain-restriction": "Firebase API Key domain restriction", "advanced-usage": "Advanced usage", "cloud-run": "Usage in Google Cloud Run", diff --git a/docs/pages/docs/usage/client-side-apis.mdx b/docs/pages/docs/usage/client-side-apis.mdx new file mode 100644 index 00000000..f9cbcbb9 --- /dev/null +++ b/docs/pages/docs/usage/client-side-apis.mdx @@ -0,0 +1,134 @@ +# Using Client-Side APIs + +The starter example uses the [inMemoryPersistence](/~https://github.com/awinogrodzki/next-firebase-auth-edge/blob/main/examples/next-typescript-starter/app/auth/firebase.ts#L28-L30) strategy to rely solely on server-side tokens, thus avoiding any consistency issues on client-side. + +This approach is recommended, but causes a few issues user may run into: + +1. **Stale tokens:** In long-running client sessions, server-side tokens may no longer be valid, requiring user to refresh the page in order to get access to valid token. This may happen when user re-opens tab after 1 hour. +2. **Unauthenticated Firebase Client SDK environment:** `inMemoryPersistence` will cause `currentUser` to be `null`, when trying to access it using [client-side APIs](https://firebase.google.com/docs/auth/web/manage-users), in most cases. This prevents us from using Firebase client-side SDKs. + +`next-firebase-auth-edge` provides a number of features that solves aforementioned issues, as listed below: + + + +### Enable Refresh Token API endpoint in Auth Middleware + +In long-running client-side sessions (e.g., if a user reopens a tab after 1 hour), the server-side token may be expired. This can cause access issues when using the token to validate external API calls or when using the `customToken` together with the `signInWithCustomToken` Firebase SDK function. + +To fix this, `authMiddleware` can expose a special endpoint to refresh client-side tokens, if current server-side token has expired. + +To enable the endpoint, define `refreshTokenPath` middleware option: + +```tsx +export async function middleware(request: NextRequest) { + return authMiddleware(request, { + loginPath: "/api/login", + logoutPath: "/api/logout", + refreshTokenPath: "/api/refresh-token" + // other options... + }); +} + +export const config = { + // Make sure to include the path in `matcher` + matcher: ["/api/login", "/api/logout", "/api/refresh-token", "/", "/((?!_next|favicon.ico|api|.*\\.).*)"], +}; +``` + +Calling `/api/refresh-token` does two things: + +1. It checks if the current token is expired. If it is, it regenerates the token and updates the cookies by returning `Set-Cookie` header with fresh token. +2. It resolves with JSON containing valid `idToken` and `customToken` + + + +### getValidIdToken + +`getValidIdToken` works together with [refresh token endpoint](/docs/usage/client-side-apis#enable-refresh-token-api-endpoint-in-auth-middleware) to provide latest, valid id token. It can be useful if you use `token` to authorize external API calls + +It requires `serverIdToken`, which is the `token` returned by [getTokens](/docs/usage/server-components#gettokens) function inside server components + +The function is designed to be fast and safe to use when called multiple times. Thus, the `/api/refresh-token` endpoint will only be called if the token has expired. + +Example usage: +```ts +import {getValidIdToken} from 'next-firebase-auth-edge/lib/next/client'; + +export async function fetchSomethingFromExternalApi(serverIdToken: string) { + const idToken = await getValidIdToken({ + serverIdToken, + refreshTokenUrl: '/api/refresh-token' + }); + + return fetch("https://some-external-api.com/api/example", { + method: "GET", + headers: { + Authorization: `Bearer ${idToken}`, + }, + }) +} +``` + + +### getValidCustomToken + +`getValidCustomToken` works together with [refresh token endpoint](/docs/usage/client-side-apis#enable-refresh-token-api-endpoint-in-auth-middleware) to provide latest, valid custom token. It can be useful if you use `customToken` together with Firebase's [signInWithCustomToken](https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase) method + +It requires `serverCustomToken`, which is the `customToken` returned by [getTokens](/docs/usage/server-components#gettokens) function inside server components + +The function is designed to be fast and safe to use when called multiple times. Thus, the `/api/refresh-token` endpoint will only be called if the token has expired. + +Example usage: +```ts +export async function signInWithServerCustomToken( + serverCustomToken: string +) { + const auth = getAuth(getFirebaseApp()); + + const customToken = await getValidCustomToken({ + serverCustomToken, + refreshTokenUrl: '/api/refresh-token' + }); + + if (!customToken) { + throw new Error('Invalid custom token'); + } + + return signInWithCustomToken(auth, customToken); +} +``` + + +## Using Firebase Client SDKs + +Firebase Client SDK exposes [signInWithCustomToken](https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase) method that allows us to access current user using custom token. + +Custom token can be obtained by calling [getTokens](/docs/usage/server-components#gettokens) function in server components + +```tsx +import {signInWithCustomToken} from 'firebase/auth'; +import {getValidCustomToken} from 'next-firebase-auth-edge/lib/next/client'; + +import {doc, getDoc, getFirestore, updateDoc, setDoc} from 'firebase/firestore'; + +export async function doSomethingWithFirestoreClient( + serverCustomToken: string +) { + const auth = getAuth(getFirebaseApp()); + + // See https://next-firebase-auth-edge-docs.vercel.app/docs/usage/client-side-apis#getvalidcustomtoken + const customToken = await getValidCustomToken({ + serverCustomToken, + refreshTokenUrl: '/api/refresh-token' + }); + + if (!customToken) { + throw new Error('Invalid custom token'); + } + + const {user: firebaseUser} = await signInWithCustomToken(auth, customToken); + + // Use client-side firestore instance + const db = getFirestore(getApp()); +} +``` \ No newline at end of file diff --git a/docs/pages/docs/usage/index.mdx b/docs/pages/docs/usage/index.mdx index 3a175827..af0d8a2c 100644 --- a/docs/pages/docs/usage/index.mdx +++ b/docs/pages/docs/usage/index.mdx @@ -42,6 +42,11 @@ If you prefer a more hands-on approach to learning, you can alternatively explor title="Refreshing credentials" href="/docs/usage/refresh-credentials" /> + } + title="Using Client-Side APIs" + href="/docs/usage/client-side-apis" + /> } title="Advanced usage" diff --git a/docs/pages/docs/usage/middleware.mdx b/docs/pages/docs/usage/middleware.mdx index 2f340d8a..3ef2347c 100644 --- a/docs/pages/docs/usage/middleware.mdx +++ b/docs/pages/docs/usage/middleware.mdx @@ -88,7 +88,7 @@ You can pass this object to `NextResponse.next({ request: { headers } })` to ena See [Modifying Request Headers in Middleware](https://vercel.com/templates/next.js/edge-functions-modify-request-header) for more information on how modified headers work ```tsx -handleValidToken: async ({ token, decodedToken }, headers) => { +handleValidToken: async ({ token, decodedToken, customToken }, headers) => { return NextResponse.next({ request: { headers, // Pass modified request headers to skip token verification in subsequent getTokens and getApiRequestTokens calls @@ -140,7 +140,7 @@ export async function middleware(request: NextRequest) { | serviceAccount | Optional in authenticated [Google Cloud Run](https://cloud.google.com/run) environment. Otherwise **required** | | Firebase project service account. | | tenantId | Optional | `string` By default `undefined` | Google Cloud Platform tenant identifier. Specify if your project supports [multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication) | | checkRevoked | Optional | `boolean` By default `false` | If true, validates the token against firebase server on each request. Unless you have a good reason, it's better not to use it. | -| handleValidToken | Optional | `(tokens: { token: string, decodedToken: DecodedIdToken }, headers: Headers) => Promise` By default returns `NextResponse.next()` | Receives id and decoded tokens and should return a promise that resolves with NextResponse. Function is called with modified request `headers` as a second parameter. By passing this parameters down to `NextResponse.next({ request: { headers } })` library won't verify the token in subsequent calls to `getTokens` or `getApiRequestTokens`, which can improve response times. | +| handleValidToken | Optional | `(tokens: { token: string, decodedToken: DecodedIdToken, customToken: string }, headers: Headers) => Promise` By default returns `NextResponse.next()` | Receives id and decoded tokens and should return a promise that resolves with NextResponse. Function is called with modified request `headers` as a second parameter. By passing this parameters down to `NextResponse.next({ request: { headers } })` library won't verify the token in subsequent calls to `getTokens` or `getApiRequestTokens`, which can improve response times. | | handleInvalidToken | Optional | `(reason: InvalidTokenReason) => Promise` By default returns `NextResponse.next()` | If passed, is called and returned if request has not been authenticated (either does not have credentials attached or credentials are malformed). Can be used to redirect unauthenticated users to specific page or pages. Called with `reason` as first argument, which can be one of `MISSING_CREDENTIALS`, `MISSING_REFRESH_TOKEN`, `MALFORMED_CREDENTIALS`, `INVALID_SIGNATURE`, `INVALID_CREDENTIALS`. See [handleInvalidToken](/docs/errors#handleinvalidtoken) to read description of each reason. | | handleError | Optional | `(error: AuthError) => Promise` By default returns `NextResponse.next()` | Receives an unhandled error that happened during authentication and should resolve with NextResponse. By default, in case of unhandled error during authentication, the library just allows application to render. This allows you to customize error handling. See [handleError](/docs/errors#handleerror) to read about possible errors | | debug | Optional | `boolean` By default `false` | Provides helpful logs than can help understand authentication flow and debug issues | diff --git a/docs/pages/docs/usage/server-components.mdx b/docs/pages/docs/usage/server-components.mdx index 77cca6b3..07c504df 100644 --- a/docs/pages/docs/usage/server-components.mdx +++ b/docs/pages/docs/usage/server-components.mdx @@ -1,6 +1,6 @@ # Usage in Server Components -The library provides `getTokens` function to extract and validate user credentials. The function can be used only in `Server Components` or [API Route Handlers](/docs/usage/app-router-api-routes). It returns `null` if there are no authentication cookies or the credentials have expired. If request contains valid user credentials, the function returns an object with `token` and `decodedToken` properties. `token` is jwt-encoded string, whereas `decodedToken` is an object representing decoded `token`. +The library provides `getTokens` function to extract and validate user credentials. The function can be used only in `Server Components` or [API Route Handlers](/docs/usage/app-router-api-routes). It returns `null` if there are no authentication cookies or the credentials have expired. If request contains valid user credentials, the function returns an object with `token`, `decodedToken` and `customToken` properties. `token` is jwt-encoded string, whereas `decodedToken` is an object representing decoded `token`. ## getTokens @@ -9,6 +9,7 @@ Example usage of `getTokens` function from `next-firebase-auth-edge`: ```tsx import {getTokens} from 'next-firebase-auth-edge'; import {cookies, headers} from 'next/headers'; +import {notFound} from 'next/navigation'; export default async function ServerComponentExample() { const tokens = await getTokens(cookies(), { @@ -26,15 +27,21 @@ export default async function ServerComponentExample() { headers: headers() }); + if (!tokens) { + return notFound(); + } + + const {token, decodedToken, customToken} = tokens; + return (
- {(tokens && ( -

- Valid token: {tokens.token} -
-

{JSON.stringify(tokens.decodedToken, undefined, 2)}
-

- )) ||

No valid user credentials

} +

+ Valid token: {token} +
+ User email: {decodedToken.email} +
+ Custom token: {customToken} +

); } diff --git a/examples/next-typescript-minimal/middleware.ts b/examples/next-typescript-minimal/middleware.ts index 425fe3ae..e0d076eb 100644 --- a/examples/next-typescript-minimal/middleware.ts +++ b/examples/next-typescript-minimal/middleware.ts @@ -13,7 +13,7 @@ export async function middleware(request: NextRequest) { cookieSignatureKeys: serverConfig.cookieSignatureKeys, cookieSerializeOptions: serverConfig.cookieSerializeOptions, serviceAccount: serverConfig.serviceAccount, - handleValidToken: async ({token, decodedToken}, headers) => { + handleValidToken: async ({token, decodedToken, customToken}, headers) => { // Authenticated user should not be able to access /login, /register and /reset-password routes if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) { return redirectToHome(request); diff --git a/examples/next-typescript-starter/README.md b/examples/next-typescript-starter/README.md index 43e6e548..b8622d12 100644 --- a/examples/next-typescript-starter/README.md +++ b/examples/next-typescript-starter/README.md @@ -29,6 +29,27 @@ yarn dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Configuring Firestore rules + +The demo shows example usage of Firestore Client SDK + +Make sure to update Firestore Database Rules of `user-counters` collection in [Firebase Console](https://console.firebase.google.com/). + +The following Firestore Database Rules validates if user has access to update specific `user-counters` database entry + +``` +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + match /user-counters/{document} { + allow read, write: if request.auth.uid == resource.data.id; + } + } +} +``` + + ## Emulator support Library provides Firebase Authentication Emulator support diff --git a/examples/next-typescript-starter/api/index.ts b/examples/next-typescript-starter/api/index.ts index f5faa848..fb0b3816 100644 --- a/examples/next-typescript-starter/api/index.ts +++ b/examples/next-typescript-starter/api/index.ts @@ -42,7 +42,7 @@ export async function logout() { }); } -export async function refreshToken() { +export async function checkEmailVerification() { const headers: Record = {}; // This is optional. Use it if your app supports App Check – https://firebase.google.com/docs/app-check @@ -52,7 +52,7 @@ export async function refreshToken() { headers['X-Firebase-AppCheck'] = appCheckTokenResponse.token; } - await fetch('/api/refresh-token', { + await fetch('/api/check-email-verification', { method: 'GET', headers }); diff --git a/examples/next-typescript-starter/app/api/refresh-token/route.ts b/examples/next-typescript-starter/app/api/check-email-verification/route.ts similarity index 100% rename from examples/next-typescript-starter/app/api/refresh-token/route.ts rename to examples/next-typescript-starter/app/api/check-email-verification/route.ts diff --git a/examples/next-typescript-starter/app/auth/AuthContext.ts b/examples/next-typescript-starter/app/auth/AuthContext.ts index 528665cc..4542a6a7 100644 --- a/examples/next-typescript-starter/app/auth/AuthContext.ts +++ b/examples/next-typescript-starter/app/auth/AuthContext.ts @@ -3,6 +3,8 @@ import {UserInfo} from 'firebase/auth'; import {Claims} from 'next-firebase-auth-edge/lib/auth/claims'; export interface User extends UserInfo { + idToken: string; + customToken: string; emailVerified: boolean; customClaims: Claims; } diff --git a/examples/next-typescript-starter/app/profile/UserProfile/UserProfile.tsx b/examples/next-typescript-starter/app/profile/UserProfile/UserProfile.tsx index bdbbd2f8..20aaf933 100644 --- a/examples/next-typescript-starter/app/profile/UserProfile/UserProfile.tsx +++ b/examples/next-typescript-starter/app/profile/UserProfile/UserProfile.tsx @@ -1,19 +1,21 @@ 'use client'; +import {getToken} from '@firebase/app-check'; import * as React from 'react'; -import {useAuth} from '../../auth/AuthContext'; -import styles from './UserProfile.module.css'; import {useLoadingCallback} from 'react-loading-hook'; -import {Button} from '../../../ui/Button'; -import {useRouter} from 'next/navigation'; + import {signOut} from 'firebase/auth'; +import {useRouter} from 'next/navigation'; +import {checkEmailVerification, logout} from '../../../api'; +import {getAppCheck} from '../../../app-check'; +import {Badge} from '../../../ui/Badge'; +import {Button} from '../../../ui/Button'; import {ButtonGroup} from '../../../ui/ButtonGroup'; import {Card} from '../../../ui/Card'; -import {Badge} from '../../../ui/Badge'; -import {getToken} from '@firebase/app-check'; -import {getAppCheck} from '../../../app-check'; +import {useAuth} from '../../auth/AuthContext'; import {getFirebaseAuth} from '../../auth/firebase'; -import {logout, refreshToken} from '../../../api'; +import styles from './UserProfile.module.css'; +import {incrementCounterUsingClientFirestore} from './user-counters'; interface UserProfileProps { count: number; @@ -82,8 +84,19 @@ export function UserProfile({count, incrementCounter}: UserProfileProps) { router.refresh(); }); + const [handleIncrementCounterClient, isIncrementCounterClientLoading] = + useLoadingCallback(async () => { + if (!user) { + return; + } + + await incrementCounterUsingClientFirestore(user.customToken); + + router.refresh(); + }); + const [handleReCheck, isReCheckLoading] = useLoadingCallback(async () => { - await refreshToken(); + await checkEmailVerification(); router.refresh(); }); @@ -94,6 +107,11 @@ export function UserProfile({count, incrementCounter}: UserProfileProps) { return null; } + const isIncrementLoading = + isIncrementCounterApiLoading || + isIncrementCounterActionPending || + isIncrementCounterClientLoading; + return (
@@ -157,22 +175,25 @@ export function UserProfile({count, incrementCounter}: UserProfileProps) { +
diff --git a/examples/next-typescript-starter/app/profile/UserProfile/user-counters.ts b/examples/next-typescript-starter/app/profile/UserProfile/user-counters.ts new file mode 100644 index 00000000..9ab5c583 --- /dev/null +++ b/examples/next-typescript-starter/app/profile/UserProfile/user-counters.ts @@ -0,0 +1,35 @@ +import {signInWithCustomToken} from 'firebase/auth'; +import {getValidCustomToken} from 'next-firebase-auth-edge/lib/next/client'; + +import {getFirebaseApp, getFirebaseAuth} from '../../auth/firebase'; +import {doc, getDoc, getFirestore, updateDoc, setDoc} from 'firebase/firestore'; + +export async function incrementCounterUsingClientFirestore( + serverCustomToken: string +) { + const auth = getFirebaseAuth(); + + // We use `getValidCustomToken` to fetch fresh `customToken` using /api/refresh-token endpoint if original custom token has expired. + // This ensures custom token is valid, even in long-running client sessions + const customToken = await getValidCustomToken({ + serverCustomToken, + refreshTokenUrl: '/api/refresh-token' + }); + + if (!customToken) { + throw new Error('Invalid custom token'); + } + + const {user: firebaseUser} = await signInWithCustomToken(auth, customToken); + const db = getFirestore(getFirebaseApp()); + const docRef = doc(db, 'user-counters', firebaseUser.uid); + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) { + const data = docSnap.data(); + + updateDoc(docRef, {count: data.count + 1}); + } else { + setDoc(docRef, {count: 1}); + } +} diff --git a/examples/next-typescript-starter/app/shared/user.ts b/examples/next-typescript-starter/app/shared/user.ts index 90222987..e47d29b3 100644 --- a/examples/next-typescript-starter/app/shared/user.ts +++ b/examples/next-typescript-starter/app/shared/user.ts @@ -2,7 +2,7 @@ import {Tokens} from 'next-firebase-auth-edge'; import {User} from '../auth/AuthContext'; import {filterStandardClaims} from 'next-firebase-auth-edge/lib/auth/claims'; -export const toUser = ({decodedToken}: Tokens): User => { +export const toUser = ({token, customToken, decodedToken}: Tokens): User => { const { uid, email, @@ -23,6 +23,8 @@ export const toUser = ({decodedToken}: Tokens): User => { phoneNumber: phoneNumber ?? null, emailVerified: emailVerified ?? false, providerId: signInProvider, - customClaims + customClaims, + idToken: token, + customToken }; }; diff --git a/examples/next-typescript-starter/middleware.ts b/examples/next-typescript-starter/middleware.ts index 9b5b5a99..5603a215 100644 --- a/examples/next-typescript-starter/middleware.ts +++ b/examples/next-typescript-starter/middleware.ts @@ -12,12 +12,13 @@ export async function middleware(request: NextRequest) { return authMiddleware(request, { loginPath: '/api/login', logoutPath: '/api/logout', + refreshTokenPath: '/api/refresh-token', apiKey: authConfig.apiKey, cookieName: authConfig.cookieName, cookieSerializeOptions: authConfig.cookieSerializeOptions, cookieSignatureKeys: authConfig.cookieSignatureKeys, serviceAccount: authConfig.serviceAccount, - handleValidToken: async ({token, decodedToken}, headers) => { + handleValidToken: async ({token, decodedToken, customToken}, headers) => { // Authenticated user should not be able to access /login, /register and /reset-password routes if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) { return redirectToHome(request); @@ -51,6 +52,7 @@ export const config = { '/', '/((?!_next|favicon.ico|__/auth|__/firebase|api|.*\\.).*)', '/api/login', - '/api/logout' + '/api/logout', + '/api/refresh-token' ] }; diff --git a/src/next/client.ts b/src/next/client.ts index c82ab347..245a0303 100644 --- a/src/next/client.ts +++ b/src/next/client.ts @@ -1,24 +1,31 @@ import {decodeJwt} from 'jose'; import {AuthError, AuthErrorCode} from '../auth/error'; -export interface GetValidIdTokenOptions { - serverIdToken: string; - refreshTokenUrl: string; - checkRevoked?: boolean; -} +class ClientTokenCache { + private cacheMap: Record = {}; -let serverIdTokenCacheMap: Record = {}; + constructor() {} -function getLatestIdToken(serverIdToken: string) { - if (!serverIdTokenCacheMap[serverIdToken]) { - return serverIdToken; + public get(value: string) { + if (!this.cacheMap[value]) { + return value; + } + + return this.cacheMap[value]; } - return serverIdTokenCacheMap[serverIdToken]; + public set(originalValue: string, value: string) { + this.cacheMap = {[originalValue]: value}; + } } -function saveLatestIdToken(serverIdToken: string, idToken: string) { - serverIdTokenCacheMap = {[serverIdToken]: idToken}; +const idTokenCache = new ClientTokenCache(); +const customTokenCache = new ClientTokenCache(); + +export interface GetValidIdTokenOptions { + serverIdToken: string; + refreshTokenUrl: string; + checkRevoked?: boolean; } export async function getValidIdToken({ @@ -31,7 +38,7 @@ export async function getValidIdToken({ return null; } - const token = getLatestIdToken(serverIdToken); + const token = idTokenCache.get(serverIdToken); const payload = decodeJwt(token); const exp = payload?.exp ?? 0; @@ -48,11 +55,49 @@ export async function getValidIdToken({ ); } - saveLatestIdToken(serverIdToken, response.idToken); + idTokenCache.set(serverIdToken, response.idToken); return response.idToken; } +export interface GetValidCustomTokenOptions { + serverCustomToken: string; + refreshTokenUrl: string; + checkRevoked?: boolean; +} + +export async function getValidCustomToken({ + serverCustomToken, + refreshTokenUrl, + checkRevoked +}: GetValidCustomTokenOptions): Promise { + // If serverCustomToken is empty, we assume user is unauthenticated and token refresh will yield null + if (!serverCustomToken) { + return null; + } + + const token = customTokenCache.get(serverCustomToken); + const payload = decodeJwt(token); + const exp = payload?.exp ?? 0; + + if (!checkRevoked && exp > Date.now() / 1000) { + return serverCustomToken; + } + + const response = await fetchApi<{customToken: string}>(refreshTokenUrl); + + if (!response?.customToken) { + throw new AuthError( + AuthErrorCode.INTERNAL_ERROR, + 'Refresh token endpoint returned invalid response. This URL should point to endpoint exposed by the middleware and configured using refreshTokenPath option' + ); + } + + customTokenCache.set(serverCustomToken, response.customToken); + + return response.customToken; +} + async function mapResponseToAuthError( response: Response, input: RequestInfo | URL, diff --git a/src/next/refresh-token.ts b/src/next/refresh-token.ts index f365a1f2..c8d2174f 100644 --- a/src/next/refresh-token.ts +++ b/src/next/refresh-token.ts @@ -28,7 +28,8 @@ export async function refreshToken( const response = new NextResponse( JSON.stringify({ - idToken: result.idToken + idToken: result.idToken, + customToken: result.customToken }), { status: 200,