Skip to content

Commit e30a7d7

Browse files
authored
Merge pull request #19893 from mozilla/fxa-12118
Support new Firefox webchannel message to resume login
2 parents eea716a + e8a3abf commit e30a7d7

7 files changed

Lines changed: 526 additions & 9 deletions

File tree

packages/fxa-settings/src/lib/channels/firefox.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export enum FirefoxCommand {
2222
// support will be added on Android (https://bugzilla.mozilla.org/show_bug.cgi?id=1968130)
2323
// and iOS (https://github.com/mozilla-mobile/firefox-ios/issues/26837)
2424
SyncPreferences = 'fxaccounts:sync_preferences',
25+
// Check if Firefox has an active OAuth flow
26+
OAuthFlowIsActive = 'fxaccounts:oauth_flow_is_active',
27+
// Start new OAuth flow and get fresh params
28+
OAuthFlowBegin = 'fxaccounts:oauth_flow_begin',
2529
}
2630

2731
export interface FirefoxMessageDetail {
@@ -37,6 +41,7 @@ export interface FirefoxMessage {
3741
stack: string;
3842
};
3943
};
44+
params?: Record<string, any>; // Some commands use params instead of data
4045
messageId: string;
4146
error?: string;
4247
}
@@ -144,6 +149,21 @@ type FxACanLinkAccountResponse = {
144149
ok: boolean;
145150
};
146151

152+
export type FxAOAuthFlowIsActiveResponse = {
153+
isActive: boolean;
154+
};
155+
156+
export type FxAOAuthFlowBeginResponse = {
157+
action: string;
158+
response_type: string;
159+
access_type: string;
160+
scope: string;
161+
client_id: string;
162+
state: string;
163+
code_challenge?: string;
164+
code_challenge_method?: string;
165+
};
166+
147167
// timeout tuned for device latency
148168
// max timeout of 100-200 ms would be optimal for an ultra-snappy UX, but could cause false negatives on mobile
149169
// compromising with 500ms for safer mobile support without being noticeably long if it times out
@@ -200,17 +220,18 @@ export class Firefox extends EventTarget {
200220
}
201221
const message = detail.message;
202222
if (message) {
203-
if (message.error || message.data.error) {
223+
if (message.error || message.data?.error) {
204224
const error = {
205225
message: message.error || message.data.error?.message,
206-
stack: message.data.error?.stack,
226+
stack: message.data?.error?.stack,
207227
};
208228
this.dispatchEvent(
209229
new CustomEvent(FirefoxCommand.Error, { detail: error })
210230
);
211231
} else {
232+
const responseData = message.data || message.params;
212233
this.dispatchEvent(
213-
new CustomEvent(message.command, { detail: message.data })
234+
new CustomEvent(message.command, { detail: responseData })
214235
);
215236
}
216237
}
@@ -376,6 +397,62 @@ export class Firefox extends EventTarget {
376397
});
377398
}
378399

400+
/** Check if Firefox has an active OAuth flow in memory. */
401+
async fxaOAuthFlowIsActive(): Promise<FxAOAuthFlowIsActiveResponse> {
402+
let timeoutId: number;
403+
return Promise.race<FxAOAuthFlowIsActiveResponse>([
404+
new Promise<FxAOAuthFlowIsActiveResponse>((resolve) => {
405+
const eventHandler = (firefoxEvent: any) => {
406+
clearTimeout(timeoutId);
407+
this.removeEventListener(
408+
FirefoxCommand.OAuthFlowIsActive,
409+
eventHandler
410+
);
411+
const response = firefoxEvent.detail as FxAOAuthFlowIsActiveResponse;
412+
resolve(response);
413+
};
414+
415+
this.addEventListener(FirefoxCommand.OAuthFlowIsActive, eventHandler);
416+
requestAnimationFrame(() => {
417+
this.send(FirefoxCommand.OAuthFlowIsActive, {});
418+
});
419+
}),
420+
new Promise<FxAOAuthFlowIsActiveResponse>((resolve) => {
421+
timeoutId = window.setTimeout(() => {
422+
// If timeout, assume no active flow (older Firefox or not supported)
423+
resolve({ isActive: false });
424+
}, DEFAULT_SEND_TIMEOUT_LENGTH_MS);
425+
}),
426+
]);
427+
}
428+
429+
/** Start new OAuth flow in Firefox and get fresh params for recovery. */
430+
async fxaOAuthFlowBegin(
431+
scopes: string[]
432+
): Promise<FxAOAuthFlowBeginResponse | null> {
433+
let timeoutId: number;
434+
return Promise.race<FxAOAuthFlowBeginResponse | null>([
435+
new Promise<FxAOAuthFlowBeginResponse | null>((resolve) => {
436+
const eventHandler = (firefoxEvent: any) => {
437+
clearTimeout(timeoutId);
438+
this.removeEventListener(FirefoxCommand.OAuthFlowBegin, eventHandler);
439+
const response = firefoxEvent.detail as FxAOAuthFlowBeginResponse;
440+
resolve(response);
441+
};
442+
443+
this.addEventListener(FirefoxCommand.OAuthFlowBegin, eventHandler);
444+
requestAnimationFrame(() => {
445+
this.send(FirefoxCommand.OAuthFlowBegin, { scopes });
446+
});
447+
}),
448+
new Promise<FxAOAuthFlowBeginResponse | null>((resolve) => {
449+
timeoutId = window.setTimeout(() => {
450+
resolve(null);
451+
}, DEFAULT_SEND_TIMEOUT_LENGTH_MS);
452+
}),
453+
]);
454+
}
455+
379456
/*
380457
* Sends an fxa_status and returns the signed in user if available.
381458
*/
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { renderHook, act } from '@testing-library/react-hooks';
6+
import { useOAuthFlowRecovery } from '.';
7+
import firefox from '../../channels/firefox';
8+
import * as ReactUtils from 'fxa-react/lib/utils';
9+
import {
10+
IntegrationType,
11+
isProbablyFirefox,
12+
isOAuthNativeIntegration,
13+
} from '../../../models';
14+
15+
jest.mock('../../channels/firefox', () => ({
16+
__esModule: true,
17+
default: {
18+
fxaOAuthFlowBegin: jest.fn(),
19+
},
20+
}));
21+
22+
jest.mock('../../../models', () => {
23+
const actual = jest.requireActual('../../../models');
24+
return {
25+
...actual,
26+
isProbablyFirefox: jest.fn(() => true),
27+
isOAuthNativeIntegration: jest.fn(() => true),
28+
};
29+
});
30+
31+
const mockIntegration = (isOAuthNative: boolean = true) => {
32+
(isOAuthNativeIntegration as unknown as jest.Mock).mockReturnValue(
33+
isOAuthNative
34+
);
35+
return {
36+
type: IntegrationType.OAuthNative,
37+
getPermissions: jest
38+
.fn()
39+
.mockReturnValue(['profile', 'https://identity.mozilla.com/apps/oldsync']),
40+
};
41+
};
42+
43+
describe('useOAuthFlowRecovery', () => {
44+
let hardNavigateSpy: jest.SpyInstance;
45+
46+
beforeEach(() => {
47+
jest.clearAllMocks();
48+
(isProbablyFirefox as unknown as jest.Mock).mockReturnValue(true);
49+
(isOAuthNativeIntegration as unknown as jest.Mock).mockReturnValue(true);
50+
hardNavigateSpy = jest
51+
.spyOn(ReactUtils, 'hardNavigate')
52+
.mockImplementation(() => {});
53+
54+
Object.defineProperty(window, 'location', {
55+
value: { search: '?flowId=abc123&utm_source=firefox' },
56+
writable: true,
57+
});
58+
});
59+
60+
afterEach(() => {
61+
hardNavigateSpy.mockRestore();
62+
});
63+
64+
it('skips recovery for non-OAuth Native integrations', async () => {
65+
const integration = mockIntegration(false);
66+
const { result } = renderHook(() =>
67+
useOAuthFlowRecovery(integration as any)
68+
);
69+
70+
let response: any;
71+
await act(async () => {
72+
response = await result.current.attemptOAuthFlowRecovery();
73+
});
74+
75+
expect(response.success).toBe(false);
76+
expect(firefox.fxaOAuthFlowBegin).not.toHaveBeenCalled();
77+
});
78+
79+
it('skips recovery for non-Firefox browsers', async () => {
80+
(isProbablyFirefox as unknown as jest.Mock).mockReturnValue(false);
81+
const integration = mockIntegration();
82+
const { result } = renderHook(() =>
83+
useOAuthFlowRecovery(integration as any)
84+
);
85+
86+
let response: any;
87+
await act(async () => {
88+
response = await result.current.attemptOAuthFlowRecovery();
89+
});
90+
91+
expect(response.success).toBe(false);
92+
expect(firefox.fxaOAuthFlowBegin).not.toHaveBeenCalled();
93+
});
94+
95+
it('navigates to /signin with fresh OAuth params on success', async () => {
96+
(firefox.fxaOAuthFlowBegin as jest.Mock).mockResolvedValue({
97+
client_id: 'new-client-id',
98+
state: 'new-state',
99+
scope: 'profile https://identity.mozilla.com/apps/oldsync',
100+
access_type: 'offline',
101+
action: 'signin',
102+
code_challenge: 'pkce-challenge',
103+
code_challenge_method: 'S256',
104+
});
105+
106+
const integration = mockIntegration();
107+
const { result } = renderHook(() =>
108+
useOAuthFlowRecovery(integration as any)
109+
);
110+
111+
let response: any;
112+
await act(async () => {
113+
response = await result.current.attemptOAuthFlowRecovery();
114+
});
115+
116+
expect(response.success).toBe(true);
117+
const url = hardNavigateSpy.mock.calls[0][0];
118+
expect(url).toContain('/signin?');
119+
expect(url).toContain('client_id=new-client-id');
120+
expect(url).toContain('state=new-state');
121+
expect(url).toContain('context=oauth_webchannel_v1');
122+
expect(url).toContain('flowId=abc123'); // preserved
123+
expect(url).toContain('utm_source=firefox'); // preserved
124+
});
125+
126+
it('sets recoveryFailed when fxaOAuthFlowBegin returns null', async () => {
127+
(firefox.fxaOAuthFlowBegin as jest.Mock).mockResolvedValue(null);
128+
129+
const integration = mockIntegration();
130+
const { result } = renderHook(() =>
131+
useOAuthFlowRecovery(integration as any)
132+
);
133+
134+
expect(result.current.recoveryFailed).toBe(false);
135+
136+
await act(async () => {
137+
await result.current.attemptOAuthFlowRecovery();
138+
});
139+
140+
expect(result.current.recoveryFailed).toBe(true);
141+
expect(hardNavigateSpy).not.toHaveBeenCalled();
142+
});
143+
144+
it('sets recoveryFailed when fxaOAuthFlowBegin throws', async () => {
145+
(firefox.fxaOAuthFlowBegin as jest.Mock).mockRejectedValue(
146+
new Error('WebChannel error')
147+
);
148+
149+
const integration = mockIntegration();
150+
const { result } = renderHook(() =>
151+
useOAuthFlowRecovery(integration as any)
152+
);
153+
154+
let response: any;
155+
await act(async () => {
156+
response = await result.current.attemptOAuthFlowRecovery();
157+
});
158+
159+
expect(response.success).toBe(false);
160+
expect(response.error).toBeDefined();
161+
expect(result.current.recoveryFailed).toBe(true);
162+
});
163+
164+
it('uses fallback scopes when getPermissions throws', async () => {
165+
(firefox.fxaOAuthFlowBegin as jest.Mock).mockResolvedValue(null);
166+
167+
const integration = {
168+
type: IntegrationType.OAuthNative,
169+
getPermissions: jest.fn().mockImplementation(() => {
170+
throw new Error('No permissions');
171+
}),
172+
};
173+
174+
const { result } = renderHook(() =>
175+
useOAuthFlowRecovery(integration as any)
176+
);
177+
178+
await act(async () => {
179+
await result.current.attemptOAuthFlowRecovery();
180+
});
181+
182+
expect(firefox.fxaOAuthFlowBegin).toHaveBeenCalledWith([
183+
'profile',
184+
'https://identity.mozilla.com/apps/oldsync',
185+
]);
186+
});
187+
});

0 commit comments

Comments
 (0)