Skip to content

Commit 7495875

Browse files
authored
Merge pull request #20048 from mozilla/FXA-13022
fix(signin): Check if session is valid for isFirefoxNonSync before rendering cached signin
2 parents a5766df + 6bd8451 commit 7495875

2 files changed

Lines changed: 204 additions & 31 deletions

File tree

packages/fxa-settings/src/pages/Signin/container.test.tsx

Lines changed: 146 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ function mockSyncDesktopV3Integration() {
9090
data: { service: 'sync' },
9191
isDesktopSync: () => true,
9292
isFirefoxClientServiceRelay: () => false,
93+
isFirefoxNonSync: () => false,
9394
getCmsInfo: () => undefined,
9495
} as Integration;
9596
}
@@ -105,6 +106,7 @@ function mockOAuthWebIntegration(
105106
data,
106107
isDesktopSync: () => false,
107108
isFirefoxClientServiceRelay: () => false,
109+
isFirefoxNonSync: () => false,
108110
getCmsInfo: () => undefined,
109111
} as Integration;
110112
}
@@ -118,6 +120,7 @@ function mockOAuthNativeIntegration() {
118120
wantsKeys: () => true,
119121
isDesktopSync: () => true,
120122
isFirefoxClientServiceRelay: () => false,
123+
isFirefoxNonSync: () => false,
121124
getCmsInfo: () => undefined,
122125
data: {
123126
service: 'sync',
@@ -126,6 +129,23 @@ function mockOAuthNativeIntegration() {
126129
} as Integration;
127130
}
128131

132+
function mockFirefoxNonSyncIntegration() {
133+
integration = {
134+
type: IntegrationType.OAuthNative,
135+
getService: () => ModelsModule.OAuthNativeServices.Relay,
136+
getClientId: () => MOCK_CLIENT_ID,
137+
isSync: () => false,
138+
wantsKeys: () => false,
139+
isDesktopSync: () => false,
140+
isFirefoxClientServiceRelay: () => true,
141+
isFirefoxNonSync: () => true,
142+
getCmsInfo: () => undefined,
143+
data: {
144+
service: ModelsModule.OAuthNativeServices.Relay,
145+
},
146+
} as Integration;
147+
}
148+
129149
function mockWebIntegration() {
130150
// Leaving for historical record. Remove once baked.
131151
// integration = {
@@ -157,7 +177,11 @@ function mockWebIntegration() {
157177
function mockFetchModule() {
158178
global.fetch = jest.fn().mockResolvedValue({
159179
ok: true,
160-
json: () => Promise.resolve({ id: 'avatar-id', url: 'https://example.com/avatar.png' }),
180+
json: () =>
181+
Promise.resolve({
182+
id: 'avatar-id',
183+
url: 'https://example.com/avatar.png',
184+
}),
161185
});
162186
}
163187

@@ -201,6 +225,7 @@ mockSensitiveDataClient.setDataType = jest.fn();
201225

202226
const mockSession = {
203227
isSessionVerified: jest.fn().mockResolvedValue(true),
228+
isValid: jest.fn().mockResolvedValue(true),
204229
sendVerificationCode: jest.fn().mockResolvedValue(undefined),
205230
verified: false,
206231
token: MOCK_SESSION_TOKEN,
@@ -280,6 +305,7 @@ function mockModelsModule() {
280305
}));
281306
(ModelsModule.useSession as jest.Mock).mockImplementation(() => mockSession);
282307
mockSession.isSessionVerified = jest.fn().mockResolvedValue(true);
308+
mockSession.isValid = jest.fn().mockResolvedValue(true);
283309
mockSession.sendVerificationCode = jest.fn().mockResolvedValue(undefined);
284310
}
285311

@@ -410,9 +436,9 @@ function mockSentryModule() {
410436
mockSentryCaptureMessage = jest.spyOn(SentryModule, 'captureMessage');
411437
}
412438

413-
function render(
414-
options?: { useFxAStatusResult?: ReturnType<typeof mockUseFxAStatus> }
415-
) {
439+
function render(options?: {
440+
useFxAStatusResult?: ReturnType<typeof mockUseFxAStatus>;
441+
}) {
416442
const useFxAStatusResult = options?.useFxAStatusResult || mockUseFxAStatus();
417443

418444
return renderWithLocalizationProvider(
@@ -940,10 +966,12 @@ describe('signin container', () => {
940966
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
941967
});
942968
// Mock passwordChangeStartWithAuthPW to succeed
943-
mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockResolvedValue({
944-
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
945-
passwordChangeToken: 'mockPasswordChangeToken',
946-
});
969+
mockAuthClient.passwordChangeStartWithAuthPW = jest
970+
.fn()
971+
.mockResolvedValue({
972+
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
973+
passwordChangeToken: 'mockPasswordChangeToken',
974+
});
947975
// Mock wrappedAccountKeys to succeed
948976
mockAuthClient.wrappedAccountKeys = jest.fn().mockResolvedValue({
949977
kA: MOCK_KB,
@@ -1034,10 +1062,12 @@ describe('signin container', () => {
10341062
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
10351063
});
10361064
// Mock passwordChangeStartWithAuthPW to throw an error
1037-
mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockRejectedValue({
1038-
errno: 999,
1039-
message: 'Test error',
1040-
});
1065+
mockAuthClient.passwordChangeStartWithAuthPW = jest
1066+
.fn()
1067+
.mockRejectedValue({
1068+
errno: 999,
1069+
message: 'Test error',
1070+
});
10411071

10421072
render();
10431073

@@ -1075,10 +1105,12 @@ describe('signin container', () => {
10751105
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
10761106
});
10771107
// Mock passwordChangeStartWithAuthPW to succeed
1078-
mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockResolvedValue({
1079-
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
1080-
passwordChangeToken: 'mockPasswordChangeToken',
1081-
});
1108+
mockAuthClient.passwordChangeStartWithAuthPW = jest
1109+
.fn()
1110+
.mockResolvedValue({
1111+
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
1112+
passwordChangeToken: 'mockPasswordChangeToken',
1113+
});
10821114
// Mock wrappedAccountKeys to throw an error
10831115
mockAuthClient.wrappedAccountKeys = jest.fn().mockRejectedValue({
10841116
errno: 999,
@@ -1122,10 +1154,12 @@ describe('signin container', () => {
11221154
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
11231155
});
11241156
// Mock passwordChangeStartWithAuthPW to succeed
1125-
mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockResolvedValue({
1126-
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
1127-
passwordChangeToken: 'mockPasswordChangeToken',
1128-
});
1157+
mockAuthClient.passwordChangeStartWithAuthPW = jest
1158+
.fn()
1159+
.mockResolvedValue({
1160+
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
1161+
passwordChangeToken: 'mockPasswordChangeToken',
1162+
});
11291163
// Mock wrappedAccountKeys to succeed
11301164
mockAuthClient.wrappedAccountKeys = jest.fn().mockResolvedValue({
11311165
kA: MOCK_KB,
@@ -1385,6 +1419,98 @@ describe('signin container', () => {
13851419
});
13861420
});
13871421

1422+
describe('Firefox non-Sync session validation', () => {
1423+
beforeEach(() => {
1424+
mockFirefoxNonSyncIntegration();
1425+
});
1426+
1427+
it('calls session.isValid and renders signin when session is valid', async () => {
1428+
const storedAccount = {
1429+
...MOCK_STORED_ACCOUNT,
1430+
email: MOCK_QUERY_PARAM_EMAIL,
1431+
sessionToken: MOCK_SESSION_TOKEN,
1432+
};
1433+
mockCurrentAccount(storedAccount);
1434+
mockUseValidateModule();
1435+
mockSession.isValid = jest.fn().mockResolvedValue(true);
1436+
1437+
render();
1438+
1439+
await waitFor(() => {
1440+
expect(mockSession.isValid).toHaveBeenCalledWith(MOCK_SESSION_TOKEN);
1441+
});
1442+
await waitFor(() => {
1443+
expect(currentSigninProps).toBeDefined();
1444+
expect(currentSigninProps?.sessionToken).toBe(MOCK_SESSION_TOKEN);
1445+
});
1446+
expect(CacheModule.discardSessionToken).not.toHaveBeenCalled();
1447+
});
1448+
1449+
it('calls session.isValid and discards token when session is invalid', async () => {
1450+
const storedAccount: { sessionToken?: string; [key: string]: any } = {
1451+
...MOCK_STORED_ACCOUNT,
1452+
email: MOCK_QUERY_PARAM_EMAIL,
1453+
sessionToken: MOCK_SESSION_TOKEN,
1454+
};
1455+
mockCurrentAccount(storedAccount);
1456+
// Make the spy actually clear the token so re-render picks it up
1457+
jest.spyOn(CacheModule, 'discardSessionToken').mockImplementation(() => {
1458+
storedAccount.sessionToken = undefined;
1459+
});
1460+
mockUseValidateModule();
1461+
mockSession.isValid = jest.fn().mockResolvedValue(false);
1462+
1463+
render();
1464+
1465+
await waitFor(() => {
1466+
expect(mockSession.isValid).toHaveBeenCalledWith(MOCK_SESSION_TOKEN);
1467+
});
1468+
await waitFor(() => {
1469+
expect(CacheModule.discardSessionToken).toHaveBeenCalled();
1470+
});
1471+
await waitFor(() => {
1472+
expect(currentSigninProps).toBeDefined();
1473+
expect(currentSigninProps?.sessionToken).toBeUndefined();
1474+
});
1475+
});
1476+
1477+
it('does not call session.isValid for Sync integration', async () => {
1478+
mockOAuthNativeIntegration();
1479+
mockCurrentAccount({
1480+
...MOCK_STORED_ACCOUNT,
1481+
email: MOCK_QUERY_PARAM_EMAIL,
1482+
sessionToken: MOCK_SESSION_TOKEN,
1483+
});
1484+
mockUseValidateModule();
1485+
mockLocationState = MOCK_LOCATION_STATE_CAN_LINK_ACCOUNT_OK;
1486+
(ensureCanLinkAcountOrRedirect as jest.Mock).mockResolvedValue(true);
1487+
1488+
render();
1489+
1490+
await waitFor(() => {
1491+
expect(currentSigninProps).toBeDefined();
1492+
});
1493+
expect(mockSession.isValid).not.toHaveBeenCalled();
1494+
});
1495+
1496+
it('does not call session.isValid for OAuth web integration', async () => {
1497+
mockOAuthWebIntegration();
1498+
mockCurrentAccount({
1499+
...MOCK_STORED_ACCOUNT,
1500+
email: MOCK_QUERY_PARAM_EMAIL,
1501+
sessionToken: MOCK_SESSION_TOKEN,
1502+
});
1503+
mockUseValidateModule();
1504+
1505+
render();
1506+
1507+
await waitFor(() => {
1508+
expect(currentSigninProps).toBeDefined();
1509+
});
1510+
expect(mockSession.isValid).not.toHaveBeenCalled();
1511+
});
1512+
});
1513+
13881514
/**
13891515
* These tests trigger the `setAccountStatus` useEffect, and because of the IIFE
13901516
* inside that, and because there are no `waitFor` calls, they need to explicitly await

packages/fxa-settings/src/pages/Signin/container.tsx

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ import {
2424
OAuthNativeSyncQueryParameters,
2525
OAuthQueryParams,
2626
} from '../../models/pages/signin';
27-
import { useCallback, useEffect, useState } from 'react';
27+
import { useCallback, useEffect, useRef, useState } from 'react';
2828
import {
2929
currentAccount,
3030
lastStoredAccount,
3131
findAccountByEmail,
32+
discardSessionToken,
3233
} from '../../lib/cache';
3334
import { hardNavigate } from 'fxa-react/lib/utils';
3435
import {
@@ -278,17 +279,24 @@ const SigninContainer = ({
278279
}, []);
279280

280281
// Avatar state - fetched directly from profile server
281-
const [avatarData, setAvatarData] = useState<{ account: { avatar: { id: string; url: string } } } | undefined>(undefined);
282+
const [avatarData, setAvatarData] = useState<
283+
{ account: { avatar: { id: string; url: string } } } | undefined
284+
>(undefined);
282285
const [avatarLoading, setAvatarLoading] = useState(true);
283286

284287
// Fetch avatar on mount from profile server (requires OAuth token)
285288
useEffect(() => {
286-
if (sessionToken && config?.servers?.profile?.url && config?.oauth?.clientId) {
289+
if (
290+
sessionToken &&
291+
config?.servers?.profile?.url &&
292+
config?.oauth?.clientId
293+
) {
287294
// Get OAuth token with profile:avatar scope (required by profile server)
288-
authClient.createOAuthToken(sessionToken, config.oauth.clientId, {
289-
scope: 'profile:avatar',
290-
ttl: PROFILE_OAUTH_TOKEN_TTL_SECONDS,
291-
})
295+
authClient
296+
.createOAuthToken(sessionToken, config.oauth.clientId, {
297+
scope: 'profile:avatar',
298+
ttl: PROFILE_OAUTH_TOKEN_TTL_SECONDS,
299+
})
292300
.then(({ access_token }) => {
293301
return fetch(`${config.servers.profile.url}/v1/avatar`, {
294302
method: 'GET',
@@ -324,6 +332,39 @@ const SigninContainer = ({
324332
}
325333
}, [authClient, config, sessionToken]);
326334

335+
// For Firefox non-Sync flows, validate the cached session token before rendering.
336+
// This is because if users "sign out" from the browser menu, FxA doesn't know
337+
// about it (and can't, because even with a web channel message, FxA would need
338+
// to be pulled up in a tab) and Fx non-Sync flows will see cached sign-in.
339+
// This provides those users with slightly less friction since they'd see an error
340+
// message and then be asked to enter their password. If the session token is
341+
// invalid, we discard it so the user sees the password field right away.
342+
const needsSessionValidation =
343+
integration.isFirefoxNonSync() && !!sessionToken;
344+
const [sessionValidationComplete, setSessionValidationComplete] = useState(
345+
!needsSessionValidation
346+
);
347+
348+
const hasValidated = useRef(false);
349+
useEffect(() => {
350+
// This ensures the useEffect is only ran once
351+
if (hasValidated.current) return;
352+
hasValidated.current = true;
353+
354+
if (!needsSessionValidation) {
355+
setSessionValidationComplete(true);
356+
return;
357+
}
358+
359+
(async () => {
360+
const isValid = await session.isValid(sessionToken!);
361+
if (!isValid) {
362+
discardSessionToken();
363+
}
364+
setSessionValidationComplete(true);
365+
})();
366+
}, [needsSessionValidation, session, sessionToken]);
367+
327368
const beginSigninHandler: BeginSigninHandler = useCallback(
328369
async (email: string, password: string) => {
329370
// - If the user came from email-first WITHOUT a linked third‑party account, Index already showed
@@ -545,8 +586,12 @@ const SigninContainer = ({
545586
);
546587
}
547588

548-
// Wait for async call (if needed) to complete
549-
if (hasLinkedAccount === undefined || hasPassword === undefined) {
589+
// Wait for async calls (if needed) to complete
590+
if (
591+
hasLinkedAccount === undefined ||
592+
hasPassword === undefined ||
593+
!sessionValidationComplete
594+
) {
550595
return (
551596
<AppLayout
552597
{...{ cmsInfo, loading: true, splitLayout, setCurrentSplitLayout }}
@@ -664,8 +709,10 @@ export async function trySignIn(
664709
metricsEnabled: response.metricsEnabled ?? true,
665710
emailVerified: response.emailVerified ?? false,
666711
sessionVerified: response.sessionVerified ?? false,
667-
verificationMethod: (response.verificationMethod || VerificationMethods.EMAIL_OTP) as VerificationMethods,
668-
verificationReason: response.verificationReason as VerificationReasons,
712+
verificationMethod: (response.verificationMethod ||
713+
VerificationMethods.EMAIL_OTP) as VerificationMethods,
714+
verificationReason:
715+
response.verificationReason as VerificationReasons,
669716
keyFetchToken: response.keyFetchToken,
670717
},
671718
...(options.keys && {

0 commit comments

Comments
 (0)