@@ -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
246255function getBrowserAPI ( ) : typeof chrome {
247256 if ( typeof browser !== "undefined" && browser ?. runtime ) {
@@ -278,8 +287,8 @@ function getTabsAPI(): typeof chrome.tabs | null {
278287function 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
495512async 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+
571756async 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+
620829async 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