Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 80 additions & 3 deletions packages/fxa-settings/src/lib/channels/firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export enum FirefoxCommand {
// support will be added on Android (https://bugzilla.mozilla.org/show_bug.cgi?id=1968130)
// and iOS (https://github.com/mozilla-mobile/firefox-ios/issues/26837)
SyncPreferences = 'fxaccounts:sync_preferences',
// Check if Firefox has an active OAuth flow
OAuthFlowIsActive = 'fxaccounts:oauth_flow_is_active',
// Start new OAuth flow and get fresh params
OAuthFlowBegin = 'fxaccounts:oauth_flow_begin',
}

export interface FirefoxMessageDetail {
Expand All @@ -37,6 +41,7 @@ export interface FirefoxMessage {
stack: string;
};
};
params?: Record<string, any>; // Some commands use params instead of data
messageId: string;
error?: string;
}
Expand Down Expand Up @@ -144,6 +149,21 @@ type FxACanLinkAccountResponse = {
ok: boolean;
};

export type FxAOAuthFlowIsActiveResponse = {
isActive: boolean;
};

export type FxAOAuthFlowBeginResponse = {
action: string;
response_type: string;
access_type: string;
scope: string;
client_id: string;
state: string;
code_challenge?: string;
code_challenge_method?: string;
};

// timeout tuned for device latency
// max timeout of 100-200 ms would be optimal for an ultra-snappy UX, but could cause false negatives on mobile
// compromising with 500ms for safer mobile support without being noticeably long if it times out
Expand Down Expand Up @@ -200,17 +220,18 @@ export class Firefox extends EventTarget {
}
const message = detail.message;
if (message) {
if (message.error || message.data.error) {
if (message.error || message.data?.error) {
const error = {
message: message.error || message.data.error?.message,
stack: message.data.error?.stack,
stack: message.data?.error?.stack,
};
this.dispatchEvent(
new CustomEvent(FirefoxCommand.Error, { detail: error })
);
} else {
const responseData = message.data || message.params;
this.dispatchEvent(
new CustomEvent(message.command, { detail: message.data })
new CustomEvent(message.command, { detail: responseData })
);
}
}
Expand Down Expand Up @@ -376,6 +397,62 @@ export class Firefox extends EventTarget {
});
}

/** Check if Firefox has an active OAuth flow in memory. */
async fxaOAuthFlowIsActive(): Promise<FxAOAuthFlowIsActiveResponse> {
let timeoutId: number;
return Promise.race<FxAOAuthFlowIsActiveResponse>([
new Promise<FxAOAuthFlowIsActiveResponse>((resolve) => {
const eventHandler = (firefoxEvent: any) => {
clearTimeout(timeoutId);
this.removeEventListener(
FirefoxCommand.OAuthFlowIsActive,
eventHandler
);
const response = firefoxEvent.detail as FxAOAuthFlowIsActiveResponse;
resolve(response);
};

this.addEventListener(FirefoxCommand.OAuthFlowIsActive, eventHandler);
requestAnimationFrame(() => {
this.send(FirefoxCommand.OAuthFlowIsActive, {});
});
}),
new Promise<FxAOAuthFlowIsActiveResponse>((resolve) => {
timeoutId = window.setTimeout(() => {
// If timeout, assume no active flow (older Firefox or not supported)
resolve({ isActive: false });
}, DEFAULT_SEND_TIMEOUT_LENGTH_MS);
}),
]);
}

/** Start new OAuth flow in Firefox and get fresh params for recovery. */
async fxaOAuthFlowBegin(
scopes: string[]
): Promise<FxAOAuthFlowBeginResponse | null> {
let timeoutId: number;
return Promise.race<FxAOAuthFlowBeginResponse | null>([
new Promise<FxAOAuthFlowBeginResponse | null>((resolve) => {
const eventHandler = (firefoxEvent: any) => {
clearTimeout(timeoutId);
this.removeEventListener(FirefoxCommand.OAuthFlowBegin, eventHandler);
const response = firefoxEvent.detail as FxAOAuthFlowBeginResponse;
resolve(response);
};

this.addEventListener(FirefoxCommand.OAuthFlowBegin, eventHandler);
requestAnimationFrame(() => {
this.send(FirefoxCommand.OAuthFlowBegin, { scopes });
});
}),
new Promise<FxAOAuthFlowBeginResponse | null>((resolve) => {
timeoutId = window.setTimeout(() => {
resolve(null);
}, DEFAULT_SEND_TIMEOUT_LENGTH_MS);
}),
]);
}

/*
* Sends an fxa_status and returns the signed in user if available.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { renderHook, act } from '@testing-library/react-hooks';
import { useOAuthFlowRecovery } from '.';
import firefox from '../../channels/firefox';
import * as ReactUtils from 'fxa-react/lib/utils';
import {
IntegrationType,
isProbablyFirefox,
isOAuthNativeIntegration,
} from '../../../models';

jest.mock('../../channels/firefox', () => ({
__esModule: true,
default: {
fxaOAuthFlowBegin: jest.fn(),
},
}));

jest.mock('../../../models', () => {
const actual = jest.requireActual('../../../models');
return {
...actual,
isProbablyFirefox: jest.fn(() => true),
isOAuthNativeIntegration: jest.fn(() => true),
};
});

const mockIntegration = (isOAuthNative: boolean = true) => {
(isOAuthNativeIntegration as unknown as jest.Mock).mockReturnValue(
isOAuthNative
);
return {
type: IntegrationType.OAuthNative,
getPermissions: jest
.fn()
.mockReturnValue(['profile', 'https://identity.mozilla.com/apps/oldsync']),
};
};

describe('useOAuthFlowRecovery', () => {
let hardNavigateSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
(isProbablyFirefox as unknown as jest.Mock).mockReturnValue(true);
(isOAuthNativeIntegration as unknown as jest.Mock).mockReturnValue(true);
hardNavigateSpy = jest
.spyOn(ReactUtils, 'hardNavigate')
.mockImplementation(() => {});

Object.defineProperty(window, 'location', {
value: { search: '?flowId=abc123&utm_source=firefox' },
writable: true,
});
});

afterEach(() => {
hardNavigateSpy.mockRestore();
});

it('skips recovery for non-OAuth Native integrations', async () => {
const integration = mockIntegration(false);
const { result } = renderHook(() =>
useOAuthFlowRecovery(integration as any)
);

let response: any;
await act(async () => {
response = await result.current.attemptOAuthFlowRecovery();
});

expect(response.success).toBe(false);
expect(firefox.fxaOAuthFlowBegin).not.toHaveBeenCalled();
});

it('skips recovery for non-Firefox browsers', async () => {
(isProbablyFirefox as unknown as jest.Mock).mockReturnValue(false);
const integration = mockIntegration();
const { result } = renderHook(() =>
useOAuthFlowRecovery(integration as any)
);

let response: any;
await act(async () => {
response = await result.current.attemptOAuthFlowRecovery();
});

expect(response.success).toBe(false);
expect(firefox.fxaOAuthFlowBegin).not.toHaveBeenCalled();
});

it('navigates to /signin with fresh OAuth params on success', async () => {
(firefox.fxaOAuthFlowBegin as jest.Mock).mockResolvedValue({
client_id: 'new-client-id',
state: 'new-state',
scope: 'profile https://identity.mozilla.com/apps/oldsync',
access_type: 'offline',
action: 'signin',
code_challenge: 'pkce-challenge',
code_challenge_method: 'S256',
});

const integration = mockIntegration();
const { result } = renderHook(() =>
useOAuthFlowRecovery(integration as any)
);

let response: any;
await act(async () => {
response = await result.current.attemptOAuthFlowRecovery();
});

expect(response.success).toBe(true);
const url = hardNavigateSpy.mock.calls[0][0];
expect(url).toContain('/signin?');
expect(url).toContain('client_id=new-client-id');
expect(url).toContain('state=new-state');
expect(url).toContain('context=oauth_webchannel_v1');
expect(url).toContain('flowId=abc123'); // preserved
expect(url).toContain('utm_source=firefox'); // preserved
});

it('sets recoveryFailed when fxaOAuthFlowBegin returns null', async () => {
(firefox.fxaOAuthFlowBegin as jest.Mock).mockResolvedValue(null);

const integration = mockIntegration();
const { result } = renderHook(() =>
useOAuthFlowRecovery(integration as any)
);

expect(result.current.recoveryFailed).toBe(false);

await act(async () => {
await result.current.attemptOAuthFlowRecovery();
});

expect(result.current.recoveryFailed).toBe(true);
expect(hardNavigateSpy).not.toHaveBeenCalled();
});

it('sets recoveryFailed when fxaOAuthFlowBegin throws', async () => {
(firefox.fxaOAuthFlowBegin as jest.Mock).mockRejectedValue(
new Error('WebChannel error')
);

const integration = mockIntegration();
const { result } = renderHook(() =>
useOAuthFlowRecovery(integration as any)
);

let response: any;
await act(async () => {
response = await result.current.attemptOAuthFlowRecovery();
});

expect(response.success).toBe(false);
expect(response.error).toBeDefined();
expect(result.current.recoveryFailed).toBe(true);
});

it('uses fallback scopes when getPermissions throws', async () => {
(firefox.fxaOAuthFlowBegin as jest.Mock).mockResolvedValue(null);

const integration = {
type: IntegrationType.OAuthNative,
getPermissions: jest.fn().mockImplementation(() => {
throw new Error('No permissions');
}),
};

const { result } = renderHook(() =>
useOAuthFlowRecovery(integration as any)
);

await act(async () => {
await result.current.attemptOAuthFlowRecovery();
});

expect(firefox.fxaOAuthFlowBegin).toHaveBeenCalledWith([
'profile',
'https://identity.mozilla.com/apps/oldsync',
]);
});
});
Loading