@@ -39,15 +39,17 @@ const DEFAULT_SESSION_MS = 3600 * 1000;
3939/** Re-check interval when an expiry-time verdict couldn't be reached. */
4040const EXPIRY_RECHECK_MS = 60 * 1000 ;
4141
42+ /** Time the IdP gets to settle a renewal before it counts as failed (30 s). */
43+ export const REFRESH_VERDICT_TIMEOUT_MS = 30 * 1000 ;
44+
4245export function useAuth ( ) {
4346 const provider = useAuthProvider ( ) ;
4447 const [ auth , setAuth ] = useState < AuthState | null > ( null ) ;
4548 const [ loading , setLoading ] = useState ( true ) ;
4649 const [ refreshEnabled , setRefreshEnabled ] = useState ( false ) ;
4750 const [ sessionExpired , setSessionExpired ] = useState ( false ) ;
4851 const isRefreshing = useRef ( false ) ;
49- const refreshTimer = useRef < ReturnType < typeof setTimeout > > ( null ) ;
50- const expiryTimer = useRef < ReturnType < typeof setTimeout > > ( null ) ;
52+ const refreshDeadline = useRef < ReturnType < typeof setTimeout > > ( null ) ;
5153
5254 // Effective expiry of the current session (0 = no session).
5355 const expiresAtRef = useRef < number > ( 0 ) ;
@@ -84,13 +86,30 @@ export function useAuth() {
8486 return ( ) => { cancelled = true ; } ;
8587 } , [ ] ) ;
8688
89+ /** A renewal settled (success, failure, or deadline) — clear the in-flight gate. */
90+ const settleRefresh = useCallback ( ( ) => {
91+ if ( refreshDeadline . current ) clearTimeout ( refreshDeadline . current ) ;
92+ refreshDeadline . current = null ;
93+ isRefreshing . current = false ;
94+ } , [ ] ) ;
95+
96+ /** Renewal failed without a hub verdict — settle and stand One Tap down. */
97+ const abandonRenewal = useCallback ( ( ) => {
98+ settleRefresh ( ) ;
99+ setRefreshEnabled ( false ) ;
100+ } , [ settleRefresh ] ) ;
101+
87102 // Single entry point for activating One Tap. Coalesces concurrent
88103 // triggers (e.g. N parallel 401s) into one refresh attempt.
89104 const triggerRefresh = useCallback ( ( ) => {
90105 if ( isRefreshing . current ) return ;
91106 isRefreshing . current = true ;
92107 setRefreshEnabled ( true ) ;
93- } , [ ] ) ;
108+ // The IdP may never call back (GIS blocked / FedCM policies). Without a
109+ // deadline, isRefreshing wedges true for the tab's lifetime: the expiry
110+ // re-check never reaches a verdict and refocus/retry are disabled.
111+ refreshDeadline . current = setTimeout ( abandonRenewal , REFRESH_VERDICT_TIMEOUT_MS ) ;
112+ } , [ abandonRenewal ] ) ;
94113
95114 // Silent renewal via the active AuthProvider. The provider collapses
96115 // "renewal returned no usable credential" into onError, so the consumer
@@ -110,17 +129,21 @@ export function useAuth() {
110129 if ( sessionLapsed ( ) ) expireSession ( ) ;
111130 } )
112131 . finally ( ( ) => {
113- isRefreshing . current = false ;
132+ settleRefresh ( ) ;
114133 } ) ;
115134 setRefreshEnabled ( false ) ;
116135 } ,
117136 onError : ( ) => {
118- isRefreshing . current = false ;
119- setRefreshEnabled ( false ) ;
137+ abandonRenewal ( ) ;
120138 if ( sessionLapsed ( ) ) expireSession ( ) ;
121139 } ,
122140 } ) ;
123141
142+ // Clear any pending renewal deadline on unmount.
143+ useEffect ( ( ) => ( ) => {
144+ if ( refreshDeadline . current ) clearTimeout ( refreshDeadline . current ) ;
145+ } , [ ] ) ;
146+
124147 // On visibility change, verify the cookie. If rejected, try One Tap
125148 // refresh; a network error means offline and never clears the session.
126149 useEffect ( ( ) => {
@@ -157,9 +180,6 @@ export function useAuth() {
157180 // Schedule silent refresh and an expiry-time server re-check from the
158181 // session's real expiry.
159182 useEffect ( ( ) => {
160- if ( refreshTimer . current ) clearTimeout ( refreshTimer . current ) ;
161- if ( expiryTimer . current ) clearTimeout ( expiryTimer . current ) ;
162-
163183 if ( ! auth ) {
164184 expiresAtRef . current = 0 ;
165185 return ;
@@ -169,15 +189,16 @@ export function useAuth() {
169189 expiresAtRef . current = expiresAt ;
170190
171191 // Silent refresh before expiry; immediately if already inside the buffer.
172- refreshTimer . current = setTimeout (
192+ const refreshTimer = setTimeout (
173193 triggerRefresh ,
174194 Math . max ( expiresAt - REFRESH_BUFFER_MS - Date . now ( ) , 0 ) ,
175195 ) ;
176196
177197 // Expiry-time re-check. Only a definitive 401/403 clears the session;
178198 // network errors reschedule (logout on evidence, not on schedule).
199+ let expiryTimer : ReturnType < typeof setTimeout > ;
179200 const scheduleExpiryCheck = ( delay : number ) => {
180- expiryTimer . current = setTimeout ( ( ) => {
201+ expiryTimer = setTimeout ( ( ) => {
181202 fetchAuthMe ( )
182203 . then ( ( me ) => {
183204 if ( me ) {
@@ -199,8 +220,8 @@ export function useAuth() {
199220 scheduleExpiryCheck ( msUntilExpiry > 0 ? msUntilExpiry + 1000 : EXPIRY_RECHECK_MS ) ;
200221
201222 return ( ) => {
202- if ( refreshTimer . current ) clearTimeout ( refreshTimer . current ) ;
203- if ( expiryTimer . current ) clearTimeout ( expiryTimer . current ) ;
223+ clearTimeout ( refreshTimer ) ;
224+ clearTimeout ( expiryTimer ) ;
204225 } ;
205226 } , [ auth , applyAuth , expireSession , triggerRefresh ] ) ;
206227
0 commit comments