Skip to content

Commit 5d03b75

Browse files
committed
Dashboard: window auth loop counter and rename to dashboard-auth-loop stat
1 parent 69604d2 commit 5d03b75

2 files changed

Lines changed: 56 additions & 22 deletions

File tree

client/dashboard/app/auth/index.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,53 @@ const BOOTSTRAP_ERROR_MESSAGE = 'Failed to bootstrap user object';
2424

2525
const AUTH_BOUNCE_COUNT_KEY = 'wpcom_auth_bounce_count';
2626

27-
function trackAuthBounce() {
27+
const AUTH_LOOP_WINDOW_MS = 10 * 1000;
28+
29+
interface AuthBounceRecord {
30+
count: number;
31+
at: number;
32+
}
33+
34+
// bumpStat when we have a login redirect loop.
35+
// We track the time of the last bounce, and if it was within a window we count
36+
// it towards our loop count. This is more reliable than clearing the counter on
37+
// successful auth, because how do we know when it is safe to clear the count?
38+
// It could be that immediately after successful auth, the very next API returns
39+
// 401 and causes a bounce, yet we would have already cleared the count.
40+
function trackAuthBounceLoop() {
2841
try {
29-
const count = Number( window.sessionStorage.getItem( AUTH_BOUNCE_COUNT_KEY ) ) + 1;
30-
window.sessionStorage.setItem( AUTH_BOUNCE_COUNT_KEY, String( count ) );
42+
const now = Date.now();
43+
const storedRecord: unknown = JSON.parse(
44+
window.sessionStorage.getItem( AUTH_BOUNCE_COUNT_KEY ) ?? 'null'
45+
);
46+
const previousRecord = isAuthBounceRecord( storedRecord ) ? storedRecord : null;
47+
const withinWindow = previousRecord !== null && now - previousRecord.at < AUTH_LOOP_WINDOW_MS;
48+
const count = withinWindow ? previousRecord.count + 1 : 1;
49+
50+
window.sessionStorage.setItem(
51+
AUTH_BOUNCE_COUNT_KEY,
52+
JSON.stringify( { count, at: now } satisfies AuthBounceRecord )
53+
);
3154

3255
if ( count >= 2 ) {
33-
bumpStat( 'dashboard-auth', 'loop' );
56+
bumpStat( 'dashboard-auth-loop', String( count ) );
3457
}
3558
} catch {
36-
// sessionStorage can be unavailable in private contexts.
59+
// sessionStorage can be unavailable in private contexts or JSON.parse may fail.
3760
}
3861
}
3962

40-
function clearAuthBounceCount() {
41-
try {
42-
window.sessionStorage.removeItem( AUTH_BOUNCE_COUNT_KEY );
43-
} catch {
44-
// sessionStorage can be unavailable in private contexts.
45-
}
63+
// Checks that what is stored in sessionStorage matches the AuthBounceRecord
64+
// shape we expect.
65+
function isAuthBounceRecord( value: unknown ): value is AuthBounceRecord {
66+
return (
67+
typeof value === 'object' &&
68+
value !== null &&
69+
'count' in value &&
70+
typeof value.count === 'number' &&
71+
'at' in value &&
72+
typeof value.at === 'number'
73+
);
4674
}
4775

4876
function getOAuthAuthorizeUrl( {
@@ -161,7 +189,7 @@ export function AuthProvider( { children }: { children: React.ReactNode } ) {
161189
authErrorHandled.current = true;
162190

163191
bumpStat( 'dashboard-auth', `bounce:${ reason }` );
164-
trackAuthBounce();
192+
trackAuthBounceLoop();
165193

166194
if ( config.isEnabled( 'oauth' ) ) {
167195
const state = crypto.randomUUID();
@@ -227,7 +255,6 @@ export function AuthProvider( { children }: { children: React.ReactNode } ) {
227255
if ( ! successStatBumped.current ) {
228256
successStatBumped.current = true;
229257
bumpStat( 'dashboard-auth', shouldUseBootstrap() ? 'success:bootstrap' : 'success:fetch' );
230-
clearAuthBounceCount();
231258
}
232259
}
233260
}, [ user ] );

client/dashboard/app/auth/test/index.test.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,29 +109,36 @@ describe( '<AuthProvider> stats', () => {
109109
await waitFor( () =>
110110
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'bounce:bootstrap' )
111111
);
112-
expect( mockedBumpStat ).not.toHaveBeenCalledWith( 'dashboard-auth', 'loop' );
112+
expect( mockedBumpStat ).not.toHaveBeenCalledWith( 'dashboard-auth-loop', expect.anything() );
113113
} );
114114

115-
test( 'bumps a loop stat when a bounce repeats without a successful auth in between', async () => {
115+
test( 'bumps a loop stat with the bounce count when a bounce repeats within the loop window', async () => {
116116
config.enable( 'wpcom-user-bootstrap' );
117-
window.sessionStorage.setItem( 'wpcom_auth_bounce_count', '1' );
117+
window.sessionStorage.setItem(
118+
'wpcom_auth_bounce_count',
119+
JSON.stringify( { count: 1, at: Date.now() } )
120+
);
118121

119122
renderAuth();
120123

121-
await waitFor( () => expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'loop' ) );
124+
await waitFor( () =>
125+
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth-loop', '2' )
126+
);
122127
} );
123128

124-
test( 'resets the bounce count when auth succeeds', async () => {
129+
test( 'does not bump a loop stat when the previous bounce is outside the loop window', async () => {
125130
config.enable( 'wpcom-user-bootstrap' );
126-
window.currentUser = testUser;
127-
window.sessionStorage.setItem( 'wpcom_auth_bounce_count', '1' );
131+
window.sessionStorage.setItem(
132+
'wpcom_auth_bounce_count',
133+
JSON.stringify( { count: 5, at: Date.now() - 60 * 1000 } )
134+
);
128135

129136
renderAuth();
130137

131-
expect( await screen.findByText( 'signed in' ) ).toBeVisible();
132138
await waitFor( () =>
133-
expect( window.sessionStorage.getItem( 'wpcom_auth_bounce_count' ) ).toBeNull()
139+
expect( mockedBumpStat ).toHaveBeenCalledWith( 'dashboard-auth', 'bounce:bootstrap' )
134140
);
141+
expect( mockedBumpStat ).not.toHaveBeenCalledWith( 'dashboard-auth-loop', expect.anything() );
135142
} );
136143

137144
test( 'bumps a bounce stat when the session expires mid-app', async () => {

0 commit comments

Comments
 (0)