go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/auth_state_provider/auth_state_provider.tsx (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { useQuery } from '@tanstack/react-query'; 16 import { createContext, useContext, useEffect, useMemo, useRef } from 'react'; 17 import { useLatest } from 'react-use'; 18 19 import { 20 AuthState, 21 msToExpire, 22 queryAuthState, 23 setAuthStateCache, 24 } from '@/common/api/auth_state'; 25 import { useStore } from '@/common/store'; 26 import { deferred } from '@/generic_libs/tools/utils'; 27 28 /** 29 * Minimum internal between auth state queries in milliseconds. 30 * 31 * Ensure there are at least some time between updates so the backend returning 32 * short-lived tokens (in case of a server bug) won't cause the query to be 33 * fired rapidly (therefore taking up too much resources with no benefit). 34 */ 35 const MIN_QUERY_INTERVAL_MS = 10000; 36 37 /** 38 * Expires the auth state tokens a bit early to ensure that the tokens won't 39 * expire on the fly. 40 */ 41 const TOKEN_BUFFER_DURATION_MS = 10000; 42 43 interface AuthStateContextValue { 44 readonly getAuthState: () => AuthState; 45 readonly getValidAuthState: () => Promise<AuthState>; 46 } 47 48 export const AuthStateContext = createContext<AuthStateContextValue | null>( 49 null, 50 ); 51 52 export interface AuthStateProviderProps { 53 readonly initialValue: AuthState; 54 readonly children: React.ReactNode; 55 } 56 57 export function AuthStateProvider({ 58 initialValue, 59 children, 60 }: AuthStateProviderProps) { 61 // Because the initial value could be an outdated value retrieved from cache, 62 // do not wait until the initial value expires before sending out the first 63 // query. 64 const { data, isPlaceholderData } = useQuery({ 65 queryKey: ['auth-state'], 66 queryFn: () => queryAuthState(), 67 placeholderData: initialValue, 68 refetchInterval(prevData) { 69 if (!prevData) { 70 return MIN_QUERY_INTERVAL_MS; 71 } 72 // Expires the auth state 1 min earlier to the tokens won't expire 73 // on the fly. 74 return Math.max(msToExpire(prevData) - 60000, MIN_QUERY_INTERVAL_MS); 75 }, 76 }); 77 // Placeholder data is provided. Cannot be null. 78 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 79 const authState = data!; 80 81 // Populate the auth state cache so it can be used by other browser tabs on 82 // start up. 83 useEffect(() => { 84 if (isPlaceholderData) { 85 return; 86 } 87 setAuthStateCache(authState); 88 }, [isPlaceholderData, authState]); 89 90 // Sync the auth state with the store so it can be used in Lit components. 91 const store = useStore(); 92 useEffect(() => store.authState.setValue(authState), [store, authState]); 93 94 // A tuple where 95 // 1. the first element is a promise that resolves when the next valid auth 96 // state OF THE SAME IDENTITY is available, and 97 // 2. the second element is a function that can mark the first element as 98 // resolved. 99 const nextValidAuthStateHandlesRef = useRef(deferred<AuthState>()); 100 useEffect(() => { 101 // Reset the handles since we will never get a valid auth state of the 102 // previous user. 103 nextValidAuthStateHandlesRef.current = deferred(); 104 }, [authState.identity]); 105 useEffect(() => { 106 if (msToExpire(authState) < TOKEN_BUFFER_DURATION_MS) { 107 return; 108 } 109 const [_, resolveNextAuthState] = nextValidAuthStateHandlesRef.current; 110 resolveNextAuthState(authState); 111 // Since a new auth state has been received, create new handles for the 112 // next "next valid auth state". 113 nextValidAuthStateHandlesRef.current = deferred(); 114 }, [authState]); 115 116 const authStateRef = useLatest(authState); 117 const ctxValue = useMemo( 118 () => ({ 119 getAuthState: () => authStateRef.current, 120 121 // Build a function that returns the next valid auth state with 122 // `nextValidAuthStateHandlesRef`. 123 // Simply using `obtainAuthState` from `@/common/api/auth_state` is not 124 // ideal because 125 // 1. on refocus, if the cached value has expired, multiple queries will 126 // be sent at the same time before any of them get the response to 127 // populate the cache, causing unnecessary network requests, and 128 // 2. `obtainAuthState` could return tokens that belong to a different 129 // user (to the user identity indicated by the cached auth state), 130 // which may cause problems if the query is cached with the user 131 // identity at call time as part of the cache key. 132 getValidAuthState: async () => { 133 if (msToExpire(authStateRef.current) >= TOKEN_BUFFER_DURATION_MS) { 134 return authStateRef.current; 135 } 136 return nextValidAuthStateHandlesRef.current[0]; 137 }, 138 }), 139 // Establish a dependency on user identity so the provided getter is 140 // refreshed whenever the identity changed. 141 // eslint-disable-next-line react-hooks/exhaustive-deps 142 [authState.identity], 143 ); 144 145 return ( 146 <AuthStateContext.Provider value={ctxValue}> 147 {children} 148 </AuthStateContext.Provider> 149 ); 150 } 151 152 /** 153 * Returns the latest auth state. For ephemeral properties (e.g. ID/access 154 * tokens, use the `useGet...Token` hooks instead. 155 * 156 * Context update happens WHEN AND ONLY WHEN the user identity changes (which 157 * can happen if the user logged into a different account via a browser tab 158 * between auth state refreshes). 159 */ 160 export function useAuthState(): Pick< 161 AuthState, 162 'identity' | 'email' | 'picture' 163 > { 164 const value = useContext(AuthStateContext); 165 166 if (!value) { 167 throw new Error('useAuthState must be used within AuthStateProvider'); 168 } 169 170 return value.getAuthState(); 171 } 172 173 /** 174 * Returns a function that resolves the latest non-expired access token of the 175 * current user when invoked. 176 * 177 * Context update happens WHEN AND ONLY WHEN the user identity changes (which 178 * can happen if the user logged into a different account via a browser tab 179 * between auth state refreshes). 180 */ 181 export function useGetAccessToken(): () => Promise<string> { 182 const value = useContext(AuthStateContext); 183 184 if (!value) { 185 throw new Error('useGetAccessToken must be used within AuthStateProvider'); 186 } 187 188 return async () => { 189 return (await value.getValidAuthState()).accessToken || ''; 190 }; 191 } 192 193 /** 194 * Returns a function that resolves the latest non-expired ID token of the 195 * current user when invoked. 196 * 197 * Context update happens WHEN AND ONLY WHEN the user identity changes (which 198 * can happen if the user logged into a different account via a browser tab 199 * between auth state refreshes). 200 */ 201 export function useGetIdToken(): () => Promise<string> { 202 const value = useContext(AuthStateContext); 203 204 if (!value) { 205 throw new Error('useGetIdToken must be used within AuthStateProvider'); 206 } 207 208 return async () => { 209 return (await value.getValidAuthState()).idToken || ''; 210 }; 211 }