Skip to content

Commit bb9581d

Browse files
CarinaWolliCarinaWolli
andauthored
fix: oauth flow for safari extension (companion) (#26795)
* oauth flow for safair extension * code clean up * fix type and lint errors --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com>
1 parent 75d611c commit bb9581d

File tree

1 file changed

+238
-23
lines changed
  • companion/extension/entrypoints/background

1 file changed

+238
-23
lines changed

companion/extension/entrypoints/background/index.ts

Lines changed: 238 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,15 @@ function getBrowserDisplayName(): string {
242242
* These provide unified access to browser extension APIs across Chrome, Firefox, Safari, and Edge
243243
*/
244244

245+
/**
246+
* Extended browser API interface that includes browserAction (Manifest V2)
247+
* Safari uses browserAction instead of action
248+
*/
249+
interface BrowserAPIWithLegacyAction {
250+
action?: typeof chrome.action;
251+
browserAction?: typeof chrome.action;
252+
}
253+
245254
// Get the appropriate browser API namespace
246255
function getBrowserAPI(): typeof chrome {
247256
if (typeof browser !== "undefined" && browser?.runtime) {
@@ -278,8 +287,8 @@ function getTabsAPI(): typeof chrome.tabs | null {
278287
function getActionAPI(): typeof chrome.action | null {
279288
const api = getBrowserAPI();
280289
// Safari uses browserAction (Manifest V2), Chrome uses action (Manifest V3)
281-
// biome-ignore lint/suspicious/noExplicitAny: Safari's browserAction API is not in Chrome types
282-
return api?.action || (api as any)?.browserAction || null;
290+
const apiWithLegacyAction = api as unknown as BrowserAPIWithLegacyAction;
291+
return api?.action || apiWithLegacyAction?.browserAction || null;
283292
}
284293

285294
// Check if the URL is a restricted page where content scripts can't run
@@ -363,16 +372,24 @@ export default defineBackground(() => {
363372
const authUrl = message.authUrl as string;
364373
const state = new URL(authUrl).searchParams.get("state");
365374

366-
if (state && storageAPI?.local) {
367-
storageAPI.local.set({ oauth_state: state }, () => {
368-
const runtime = getRuntimeAPI();
369-
if (runtime?.lastError) {
370-
devLog.warn("Failed to store OAuth state:", runtime.lastError.message);
371-
}
372-
});
373-
}
375+
// Store state before starting OAuth to prevent race conditions
376+
const storeState = async () => {
377+
if (!state) {
378+
throw new Error("No state parameter in auth URL");
379+
}
380+
if (!storageAPI?.local) {
381+
throw new Error("Storage API not available");
382+
}
383+
try {
384+
await storageAPI.local.set({ oauth_state: state });
385+
} catch (error) {
386+
devLog.error("Failed to store OAuth state:", error);
387+
throw new Error("Failed to initialize OAuth flow - cannot store state");
388+
}
389+
};
374390

375-
handleExtensionOAuth(authUrl)
391+
storeState()
392+
.then(() => handleExtensionOAuth(authUrl))
376393
.then((responseUrl) => sendResponse({ success: true, responseUrl }))
377394
.catch((error) => sendResponse({ success: false, error: error.message }));
378395
return true;
@@ -493,9 +510,15 @@ export default defineBackground(() => {
493510
});
494511

495512
async function handleExtensionOAuth(authUrl: string): Promise<string> {
496-
const identityAPI = getIdentityAPI();
497513
const browserType = detectBrowser();
498514

515+
// Safari uses tabs-based OAuth flow
516+
if (browserType === BrowserType.Safari) {
517+
return handleSafariTabsOAuth(authUrl);
518+
}
519+
520+
const identityAPI = getIdentityAPI();
521+
499522
if (!identityAPI) {
500523
throw new Error(`Identity API not available in ${getBrowserDisplayName()}`);
501524
}
@@ -521,8 +544,8 @@ async function handleExtensionOAuth(authUrl: string): Promise<string> {
521544
}
522545

523546
return new Promise((resolve, reject) => {
524-
// Firefox and Safari use Promise-based API
525-
if (browserType === BrowserType.Firefox || browserType === BrowserType.Safari) {
547+
// Firefox uses Promise-based API
548+
if (browserType === BrowserType.Firefox) {
526549
try {
527550
const result = identityAPI.launchWebAuthFlow({
528551
url: authUrl,
@@ -568,6 +591,168 @@ async function handleExtensionOAuth(authUrl: string): Promise<string> {
568591
});
569592
}
570593

594+
/**
595+
* Handle Safari OAuth using tabs-based flow
596+
* Opens OAuth in a new tab and captures the redirect callback
597+
*/
598+
async function handleSafariTabsOAuth(authUrl: string): Promise<string> {
599+
// Extract the redirect URI to know what to watch for
600+
const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
601+
if (!redirectUri) {
602+
throw new Error("redirect_uri not found in auth URL");
603+
}
604+
605+
const redirectUrlObj = new URL(redirectUri);
606+
const redirectOrigin = redirectUrlObj.origin; // scheme + host + port
607+
const redirectPath = redirectUrlObj.pathname;
608+
609+
return new Promise((resolve, reject) => {
610+
const tabsAPI = getTabsAPI();
611+
if (!tabsAPI) {
612+
reject(new Error("Tabs API not available"));
613+
return;
614+
}
615+
616+
let oauthTabId: number | null = null;
617+
let isResolved = false;
618+
619+
const timeoutId = setTimeout(
620+
() => {
621+
if (!isResolved) {
622+
cleanup();
623+
reject(new Error("OAuth flow timed out after 5 minutes"));
624+
}
625+
},
626+
5 * 60 * 1000
627+
);
628+
629+
const tabUpdateListener = (tabId: number, _changeInfo: unknown, tab: chrome.tabs.Tab) => {
630+
// Only process updates for our OAuth tab
631+
if (tabId !== oauthTabId) return;
632+
633+
// Check if the tab navigated to the redirect URI
634+
if (tab.url) {
635+
try {
636+
const tabUrl = new URL(tab.url);
637+
638+
// Check if this is our OAuth callback URL - exact origin and path match
639+
if (tabUrl.origin === redirectOrigin && tabUrl.pathname === redirectPath) {
640+
if (!isResolved) {
641+
const error = tabUrl.searchParams.get("error");
642+
if (error) {
643+
isResolved = true;
644+
cleanup();
645+
const errorDescription = tabUrl.searchParams.get("error_description") || error;
646+
647+
// Close the tab after capturing error
648+
if (oauthTabId !== null) {
649+
tabsAPI.remove(oauthTabId).catch(() => {
650+
// Ignore errors when closing tab
651+
});
652+
}
653+
654+
reject(new Error(`OAuth error: ${errorDescription}`));
655+
return;
656+
}
657+
658+
const code = tabUrl.searchParams.get("code");
659+
if (!code) {
660+
// No code yet, keep listening (might be an intermediate redirect)
661+
return;
662+
}
663+
664+
const state = tabUrl.searchParams.get("state");
665+
if (!state) {
666+
isResolved = true;
667+
cleanup();
668+
669+
// Close the tab
670+
if (oauthTabId !== null) {
671+
tabsAPI.remove(oauthTabId).catch(() => {
672+
// Ignore errors when closing tab
673+
});
674+
}
675+
676+
reject(new Error("No state parameter in OAuth callback"));
677+
return;
678+
}
679+
680+
// State validation - only close tab after validation succeeds
681+
isResolved = true;
682+
cleanup();
683+
684+
validateOAuthStateWithoutCleanup(state)
685+
.then(() => {
686+
// State is valid, close the tab and resolve
687+
// Note: State is NOT cleaned up here, token exchange will clean it up
688+
if (oauthTabId !== null) {
689+
tabsAPI.remove(oauthTabId).catch(() => {
690+
// Ignore errors when closing tab
691+
});
692+
}
693+
if (!tab.url) {
694+
reject(new Error("Tab URL is undefined"));
695+
return;
696+
}
697+
resolve(tab.url);
698+
})
699+
.catch((error) => {
700+
// State validation failed, close the tab and reject
701+
if (oauthTabId !== null) {
702+
tabsAPI.remove(oauthTabId).catch(() => {
703+
// Ignore errors when closing tab
704+
});
705+
}
706+
reject(error);
707+
});
708+
}
709+
}
710+
} catch (_error) {
711+
// Invalid URL, ignore
712+
}
713+
}
714+
};
715+
716+
// Listen for tab removal (user closed the OAuth tab)
717+
const tabRemovedListener = (tabId: number) => {
718+
if (tabId === oauthTabId && !isResolved) {
719+
isResolved = true;
720+
cleanup();
721+
reject(new Error("OAuth cancelled by user"));
722+
}
723+
};
724+
725+
const cleanup = () => {
726+
clearTimeout(timeoutId);
727+
tabsAPI.onUpdated.removeListener(tabUpdateListener);
728+
tabsAPI.onRemoved.removeListener(tabRemovedListener);
729+
};
730+
731+
// Register listeners
732+
tabsAPI.onUpdated.addListener(tabUpdateListener);
733+
tabsAPI.onRemoved.addListener(tabRemovedListener);
734+
735+
// Open OAuth URL in a new tab
736+
tabsAPI
737+
.create({
738+
url: authUrl,
739+
active: true,
740+
})
741+
.then((tab) => {
742+
if (tab.id) {
743+
oauthTabId = tab.id;
744+
} else {
745+
cleanup();
746+
reject(new Error("Failed to create OAuth tab"));
747+
}
748+
})
749+
.catch((error) => {
750+
cleanup();
751+
reject(new Error(`Failed to open OAuth tab: ${error.message}`));
752+
});
753+
});
754+
}
755+
571756
async function handleTokenExchange(
572757
tokenRequest: Record<string, string>,
573758
tokenEndpoint: string,
@@ -617,31 +802,61 @@ async function handleTokenExchange(
617802
return tokens;
618803
}
619804

805+
/**
806+
* Validates OAuth state without cleaning it up (for Safari flow)
807+
* Token exchange will clean up the state after successful validation
808+
*/
809+
async function validateOAuthStateWithoutCleanup(state: string): Promise<void> {
810+
const storageAPI = getStorageAPI();
811+
if (!storageAPI?.local) {
812+
// Fail closed: if we can't access storage, we can't validate state
813+
throw new Error("Storage API not available - cannot validate OAuth state");
814+
}
815+
816+
const result = await storageAPI.local.get(["oauth_state"]);
817+
const storedState = result.oauth_state as string | undefined;
818+
819+
if (!storedState) {
820+
throw new Error("No stored OAuth state found - possible CSRF attack");
821+
}
822+
823+
if (storedState !== state) {
824+
devLog.error("State parameter mismatch - possible CSRF attack");
825+
throw new Error("Invalid state parameter - possible CSRF attack");
826+
}
827+
}
828+
620829
async function validateOAuthState(state: string): Promise<void> {
621830
const storageAPI = getStorageAPI();
622831
if (!storageAPI?.local) {
623-
devLog.warn("Storage API not available for state validation");
624-
return;
832+
// Fail closed: if we can't access storage, we can't validate state
833+
throw new Error("Storage API not available - cannot validate OAuth state");
625834
}
626835

627836
try {
628837
const result = await storageAPI.local.get(["oauth_state"]);
629838
const storedState = result.oauth_state as string | undefined;
630839

631-
if (storedState && storedState !== state) {
840+
// Fail closed: state must exist and match exactly
841+
if (!storedState) {
842+
throw new Error("No stored OAuth state found");
843+
}
844+
845+
if (storedState !== state) {
632846
await storageAPI.local.remove("oauth_state");
633847
devLog.error("State parameter mismatch - possible CSRF attack");
634848
throw new Error("Invalid state parameter - possible CSRF attack");
635849
}
636850

637-
if (storedState === state) {
638-
await storageAPI.local.remove("oauth_state");
639-
}
851+
await storageAPI.local.remove("oauth_state");
640852
} catch (error) {
641-
if (error instanceof Error && error.message.includes("Invalid state parameter")) {
642-
throw error;
853+
// Clean up on any error
854+
try {
855+
await storageAPI.local.remove("oauth_state");
856+
} catch {
857+
// Ignore cleanup errors
643858
}
644-
devLog.warn("State validation warning:", error);
859+
throw error;
645860
}
646861
}
647862

0 commit comments

Comments
 (0)