UNPKG

@sanity/sdk

Version:
295 lines (271 loc) 10.6 kB
import { distinctUntilChanged, exhaustMap, filter, firstValueFrom, from, map, Observable, type Subscription, switchMap, takeWhile, timer, } from 'rxjs' import {type StoreContext} from '../store/defineStore' import {DEFAULT_API_VERSION} from './authConstants' import {AuthStateType} from './authStateType' import {type AuthState, type AuthStoreState} from './authStore' const REFRESH_INTERVAL = 12 * 60 * 60 * 1000 // 12 hours in milliseconds const LOCK_NAME = 'sanity-token-refresh-lock' /** @internal */ export function getLastRefreshTime(storageArea: Storage | undefined, storageKey: string): number { try { const data = storageArea?.getItem(`${storageKey}_last_refresh`) const parsed = data ? parseInt(data, 10) : 0 return isNaN(parsed) ? 0 : parsed } catch { return 0 } } /** @internal */ export function setLastRefreshTime(storageArea: Storage | undefined, storageKey: string): void { try { storageArea?.setItem(`${storageKey}_last_refresh`, Date.now().toString()) } catch { // Ignore storage errors } } /** @internal */ export function getNextRefreshDelay(storageArea: Storage | undefined, storageKey: string): number { const lastRefresh = getLastRefreshTime(storageArea, storageKey) if (!lastRefresh) return 0 const now = Date.now() const nextRefreshTime = lastRefresh + REFRESH_INTERVAL return Math.max(0, nextRefreshTime - now) } function createTokenRefreshStream( token: string, clientFactory: AuthStoreState['options']['clientFactory'], apiHost: string | undefined, ): Observable<{token: string}> { return new Observable((subscriber) => { const client = clientFactory({ apiVersion: DEFAULT_API_VERSION, requestTagPrefix: 'token-refresh', useProjectHostname: false, useCdn: false, token, ignoreBrowserTokenWarning: true, ...(apiHost && {apiHost}), }) const subscription = client.observable .request<{token: string}>({ uri: 'auth/refresh-token', method: 'POST', body: { token, }, }) .subscribe(subscriber) return () => subscription.unsubscribe() }) } async function acquireTokenRefreshLock( refreshFn: () => Promise<void>, storageArea: Storage | undefined, storageKey: string, ): Promise<boolean> { if (!navigator.locks) { // If Web Locks API is not supported, perform an immediate, uncoordinated refresh. // eslint-disable-next-line no-console console.warn('Web Locks API not supported. Proceeding with uncoordinated refresh.') await refreshFn() setLastRefreshTime(storageArea, storageKey) return true // Indicate success to allow stream processing, though it won't loop. } try { // Attempt to acquire an exclusive lock for token refresh coordination. // The callback handles the continuous refresh cycle while the lock is held. const result = await navigator.locks.request(LOCK_NAME, {mode: 'exclusive'}, async (lock) => { if (!lock) return false // Lock not granted // Problematic infinite loop - needs redesign for graceful termination. // This loop continuously refreshes the token at REFRESH_INTERVAL. while (true) { const delay = getNextRefreshDelay(storageArea, storageKey) if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)) } try { await refreshFn() setLastRefreshTime(storageArea, storageKey) } catch (error) { // eslint-disable-next-line no-console console.error('Token refresh failed within lock:', error) // Decide how to handle errors - break, retry, etc.? Currently logs and continues. } // Wait for the next interval await new Promise((resolve) => setTimeout(resolve, REFRESH_INTERVAL)) } // Unreachable due to while(true) }) // The promise from navigator.locks.request resolves with the callback's return value, // but only if the callback finishes. The infinite loop prevents this. return result === true } catch (error) { // Handle potential errors during the initial lock request itself. // eslint-disable-next-line no-console console.error('Failed to request token refresh lock:', error) return false // Indicate lock acquisition failure. } } function shouldRefreshToken(lastRefresh: number | undefined): boolean { if (!lastRefresh) return true const timeSinceLastRefresh = Date.now() - lastRefresh return timeSinceLastRefresh >= REFRESH_INTERVAL } /** * @internal */ export const refreshStampedToken = ({state}: StoreContext<AuthStoreState>): Subscription => { const {clientFactory, apiHost, storageArea, storageKey} = state.get().options const refreshToken$ = state.observable.pipe( map((storeState) => ({ authState: storeState.authState, dashboardContext: storeState.dashboardContext, })), filter( ( storeState, ): storeState is { authState: Extract<AuthState, {type: AuthStateType.LOGGED_IN}> dashboardContext: AuthStoreState['dashboardContext'] } => storeState.authState.type === AuthStateType.LOGGED_IN, ), distinctUntilChanged( (prev, curr) => prev.authState.type === curr.authState.type && prev.authState.token === curr.authState.token && // Only care about token for distinctness here prev.dashboardContext === curr.dashboardContext, ), // Make distinctness check explicit filter((storeState) => storeState.authState.token.includes('-st')), // Ensure we only try to refresh stamped tokens exhaustMap((storeState) => { // USE exhaustMap instead of switchMap // Create a function that performs a single refresh and updates state/storage const performRefresh = async () => { // Read the latest token directly from state inside refresh const currentState = state.get() if (currentState.authState.type !== AuthStateType.LOGGED_IN) { throw new Error('User logged out before refresh could complete') // Abort refresh } const currentToken = currentState.authState.token const response = await firstValueFrom( createTokenRefreshStream(currentToken, clientFactory, apiHost), ) state.set('setRefreshStampedToken', (prev) => ({ authState: prev.authState.type === AuthStateType.LOGGED_IN ? {...prev.authState, token: response.token} : prev.authState, })) storageArea?.setItem(storageKey, JSON.stringify({token: response.token})) } if (storeState.dashboardContext) { return new Observable<{token: string}>((subscriber) => { const visibilityHandler = () => { const currentState = state.get() if ( document.visibilityState === 'visible' && currentState.authState.type === AuthStateType.LOGGED_IN && shouldRefreshToken(currentState.authState.lastTokenRefresh) ) { createTokenRefreshStream( currentState.authState.token, clientFactory, apiHost, ).subscribe({ next: (response) => { state.set('setRefreshStampedToken', (prev) => ({ authState: prev.authState.type === AuthStateType.LOGGED_IN ? { ...prev.authState, token: response.token, lastTokenRefresh: Date.now(), } : prev.authState, })) subscriber.next(response) }, error: (error) => subscriber.error(error), }) } } const timerSubscription = timer(REFRESH_INTERVAL, REFRESH_INTERVAL) .pipe( filter(() => document.visibilityState === 'visible'), switchMap(() => { const currentState = state.get().authState if (currentState.type !== AuthStateType.LOGGED_IN) { throw new Error('User logged out before refresh could complete') } return createTokenRefreshStream(currentState.token, clientFactory, apiHost) }), ) .subscribe({ next: (response) => { state.set('setRefreshStampedToken', (prev) => ({ authState: prev.authState.type === AuthStateType.LOGGED_IN ? { ...prev.authState, token: response.token, lastTokenRefresh: Date.now(), } : prev.authState, })) subscriber.next(response) }, error: (error) => subscriber.error(error), }) document.addEventListener('visibilitychange', visibilityHandler) return () => { document.removeEventListener('visibilitychange', visibilityHandler) timerSubscription.unsubscribe() } }).pipe( takeWhile(() => state.get().authState.type === AuthStateType.LOGGED_IN), map((response: {token: string}) => ({token: response.token})), ) } // If not in dashboard context, use lock-based refresh return from(acquireTokenRefreshLock(performRefresh, storageArea, storageKey)).pipe( filter((hasLock) => hasLock), map(() => { const currentState = state.get().authState if (currentState.type !== AuthStateType.LOGGED_IN) { throw new Error('User logged out before refresh could complete') } return {token: currentState.token} as const }), ) }), ) return refreshToken$.subscribe({ next: (response: {token: string}) => { state.set('setRefreshStampedToken', (prev) => ({ authState: prev.authState.type === AuthStateType.LOGGED_IN ? { ...prev.authState, token: response.token, lastTokenRefresh: Date.now(), } : prev.authState, })) storageArea?.setItem(storageKey, JSON.stringify({token: response.token})) }, error: (error) => { state.set('setRefreshStampedTokenError', {authState: {type: AuthStateType.ERROR, error}}) }, }) }