diff --git a/package-lock.json b/package-lock.json index 51cbda9..99d30d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "@propelauth/nextjs", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@propelauth/nextjs", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { - "@propelauth/node-apis": "^2.1.21", + "@propelauth/node-apis": "^2.1.22", "jose": "^5.2.4" }, "devDependencies": { @@ -615,9 +615,9 @@ } }, "node_modules/@propelauth/node-apis": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@propelauth/node-apis/-/node-apis-2.1.21.tgz", - "integrity": "sha512-YFZiHjG8MpJ6IIOXC88RTk4HYsGrjA8zLOqSpuCdZuHCX2HrkQX8JduXM2oAsFzBvCQbxquNbhEpxYRx7dCKJg==" + "version": "2.1.22", + "resolved": "https://registry.npmjs.org/@propelauth/node-apis/-/node-apis-2.1.22.tgz", + "integrity": "sha512-yjcVQRWuYT5B5gbbJTz/1S5vEfB+djQIoH906Vb48Ku683ada+MPHF28FHjeqM0xmqhIgPkiyrx/3f4sl0UUMQ==" }, "node_modules/@swc/counter": { "version": "0.1.3", @@ -2256,9 +2256,9 @@ } }, "@propelauth/node-apis": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@propelauth/node-apis/-/node-apis-2.1.21.tgz", - "integrity": "sha512-YFZiHjG8MpJ6IIOXC88RTk4HYsGrjA8zLOqSpuCdZuHCX2HrkQX8JduXM2oAsFzBvCQbxquNbhEpxYRx7dCKJg==" + "version": "2.1.22", + "resolved": "https://registry.npmjs.org/@propelauth/node-apis/-/node-apis-2.1.22.tgz", + "integrity": "sha512-yjcVQRWuYT5B5gbbJTz/1S5vEfB+djQIoH906Vb48Ku683ada+MPHF28FHjeqM0xmqhIgPkiyrx/3f4sl0UUMQ==" }, "@swc/counter": { "version": "0.1.3", diff --git a/package.json b/package.json index 6f0dcc0..4995d5a 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "jest" + "test": "jest", + "prepublishOnly": "npm run build" }, "devDependencies": { "@types/node": "^20.3.1", diff --git a/src/client/AuthProvider.tsx b/src/client/AuthProvider.tsx index e3eee2b..f7f3073 100644 --- a/src/client/AuthProvider.tsx +++ b/src/client/AuthProvider.tsx @@ -1,10 +1,17 @@ 'use client' -import React, { useCallback, useEffect, useReducer } from 'react' -import { doesLocalStorageMatch, hasWindow, isEqual, saveUserToLocalStorage, USER_INFO_KEY } from './utils' import { useRouter } from 'next/navigation.js' -import { User } from './useUser' +import React, { useCallback, useEffect, useReducer, useState } from 'react' import { toOrgIdToOrgMemberInfo } from '../user' +import { User } from './useUser' +import { + currentTimeSecs, + doesLocalStorageMatch, + hasWindow, + isEqual, + saveUserToLocalStorage, + USER_INFO_KEY, +} from './utils' export interface RedirectToSignupOptions { postSignupRedirectPath?: string @@ -46,9 +53,12 @@ interface InternalAuthState { setActiveOrg: (orgId: string) => Promise } +const DEFAULT_MIN_SECONDS_BEFORE_REFRESH = 120 + export type AuthProviderProps = { authUrl: string reloadOnAuthChange?: boolean + minSecondsBeforeRefresh?: number children?: React.ReactNode refreshOnFocus?: boolean } @@ -74,7 +84,7 @@ type AuthState = { authChangeDetected: boolean } -const initialAuthState = { +const initialAuthState: AuthState = { loading: true, userAndAccessToken: { user: undefined, @@ -129,7 +139,9 @@ function authStateReducer(_state: AuthState, action: AuthStateAction): AuthState } export const AuthProvider = (props: AuthProviderProps) => { + const minSecondsBeforeRefresh = props.minSecondsBeforeRefresh ?? DEFAULT_MIN_SECONDS_BEFORE_REFRESH const [authState, dispatchInner] = useReducer(authStateReducer, initialAuthState) + const [lastRefresh, setLastRefresh] = useState() const router = useRouter() const reloadOnAuthChange = props.reloadOnAuthChange ?? true @@ -157,6 +169,7 @@ export const AuthProvider = (props: AuthProviderProps) => { const action = await apiGetUserInfo() if (!didCancel && !action.error) { dispatch(action) + setLastRefresh(currentTimeSecs()) } } @@ -185,11 +198,20 @@ export const AuthProvider = (props: AuthProviderProps) => { } if (!action.error) { dispatch(action) + setLastRefresh(currentTimeSecs()) } else if (action.error === 'unexpected') { clearAndSetRetryTimer() } } + // If we were offline or on a different tab, when we return, refetch auth info + // Some browsers trigger focus more often than we'd like, so we'll debounce a little here as well + const refreshOnOnlineOrFocus = async function () { + if (!lastRefresh || currentTimeSecs() > lastRefresh + minSecondsBeforeRefresh) { + await refreshToken() + } + } + async function onStorageEvent(event: StorageEvent) { if ( event.key === USER_INFO_KEY && @@ -203,11 +225,11 @@ export const AuthProvider = (props: AuthProviderProps) => { if (hasWindow()) { window.addEventListener('storage', onStorageEvent) - window.addEventListener('online', refreshToken) + window.addEventListener('online', refreshOnOnlineOrFocus) // Default for refreshOnFocus is true if (props.refreshOnFocus !== false) { - window.addEventListener('focus', refreshToken) + window.addEventListener('focus', refreshOnOnlineOrFocus) } } @@ -219,11 +241,11 @@ export const AuthProvider = (props: AuthProviderProps) => { } if (hasWindow()) { window.removeEventListener('storage', onStorageEvent) - window.removeEventListener('online', refreshToken) - window.removeEventListener('focus', refreshToken) + window.removeEventListener('online', refreshOnOnlineOrFocus) + window.removeEventListener('focus', refreshOnOnlineOrFocus) } } - }, [dispatch, authState.userAndAccessToken.user]) + }, [dispatch, authState.userAndAccessToken.user, lastRefresh]) const logout = useCallback(async () => { await fetch('/api/auth/logout', { diff --git a/src/client/utils.ts b/src/client/utils.ts index 22f531f..903401e 100644 --- a/src/client/utils.ts +++ b/src/client/utils.ts @@ -7,6 +7,10 @@ export function hasWindow(): boolean { return typeof window !== 'undefined' } +export const currentTimeSecs = (): number => { + return Math.floor(Date.now() / 1000) +} + export function saveUserToLocalStorage(user: User | undefined) { if (user) { localStorage.setItem(USER_INFO_KEY, JSON.stringify(user)) @@ -79,4 +83,4 @@ export function isEqual(a: any, b: any): boolean { // We need to make sure that the comparison is done with objects that have gone through the same transformation, so we mimic the localStorage transformation to json and back function jsonSerialize(userFromToken: UserFromToken) { return JSON.parse(JSON.stringify(userFromToken)) -} \ No newline at end of file +} diff --git a/src/server/app-router-index.ts b/src/server/app-router-index.ts index 40bbacc..6aa4054 100644 --- a/src/server/app-router-index.ts +++ b/src/server/app-router-index.ts @@ -1,11 +1,12 @@ -export { UnauthorizedException, ConfigurationException } from './exceptions' export { + authMiddleware, + getAccessToken, + getCurrentPath, + getCurrentUrl, getRouteHandlers, getUser, + getUserAndAccessToken, getUserOrRedirect, - getAccessToken, - authMiddleware, - getCurrentUrl, - getCurrentPath, } from './app-router' -export type { RouteHandlerArgs, RedirectOptions } from './app-router' +export type { RedirectOptions, RouteHandlerArgs } from './app-router' +export { ConfigurationException, UnauthorizedException } from './exceptions' diff --git a/src/server/app-router.ts b/src/server/app-router.ts index 714a2e5..13e4e91 100644 --- a/src/server/app-router.ts +++ b/src/server/app-router.ts @@ -1,6 +1,8 @@ -import { redirect } from 'next/navigation.js' import { cookies, headers } from 'next/headers.js' +import { redirect } from 'next/navigation.js' import { NextRequest, NextResponse } from 'next/server.js' +import { ACTIVE_ORG_ID_COOKIE_NAME } from '../shared' +import { UserFromToken } from './index' import { ACCESS_TOKEN_COOKIE_NAME, CALLBACK_PATH, @@ -22,8 +24,6 @@ import { validateAccessToken, validateAccessTokenOrUndefined, } from './shared' -import { UserFromToken } from './index' -import { ACTIVE_ORG_ID_COOKIE_NAME } from '../shared' export type RedirectOptions = | { @@ -56,6 +56,27 @@ export async function getUser(): Promise { return undefined } +export type UserAndAccessToken = + | { + user: UserFromToken + accessToken: string + } + | { + user: undefined + accessToken: never + } + +export async function getUserAndAccessToken(): Promise { + const accessToken = getAccessToken() + if (accessToken) { + const user = await validateAccessTokenOrUndefined(accessToken) + if (user) { + return { user, accessToken } + } + } + return { user: undefined, accessToken: undefined as never } +} + export function getAccessToken(): string | undefined { return headers().get(CUSTOM_HEADER_FOR_ACCESS_TOKEN) || cookies().get(ACCESS_TOKEN_COOKIE_NAME)?.value }