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  }