diff --git a/README.md b/README.md index e591bbc..12ccf1a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ pnpm add @addon-core/browser - [extension](docs/extension.md) - [history](docs/history.md) - [i18n](docs/i18n.md) +- [identity](docs/identity.md) — Chrome full support; Firefox, Edge, and Opera support the portable web auth flow; Safari is not supported. - [idle](docs/idle.md) - [management](docs/management.md) - [notifications](docs/notifications.md) @@ -77,49 +78,67 @@ pnpm add @addon-core/browser ## Usage examples -- setActionPopup (works in MV2 & MV3): +### Action API across MV2 and MV3 ```ts -import { setActionPopup } from "@addon-core/browser"; +import {setActionPopup, setBadgeText} from "@addon-core/browser"; await setActionPopup("popup.html"); -// Optional per-tab usage (when you have a tab id): -// await setActionPopup("popup.html", someTabId); +await setBadgeText("ON"); ``` -- getCurrentTab: +`setActionPopup()` works with `chrome.action` in Manifest V3 and `chrome.browserAction` in Manifest V2. + +### Tabs and events ```ts -import { getCurrentTab } from "@addon-core/browser"; +import {getActiveTab, onTabUpdated} from "@addon-core/browser"; + +const tab = await getActiveTab(); -const tab = await getCurrentTab(); -if (tab?.id) { - console.log("Current tab id:", tab.id); -} +const off = onTabUpdated((tabId, changeInfo) => { + if (tabId === tab.id && changeInfo.status === "complete") { + off(); + } +}); ``` -- onTabUpdated (with unsubscribe): +Event helpers return an unsubscribe function, making listener lifecycles easy to control. + +### Context menus and tab messaging ```ts -import { onTabUpdated } from "@addon-core/browser"; +import {createOrUpdateContextMenu, onContextMenusClicked, sendTabMessage} from "@addon-core/browser"; -const off = onTabUpdated((tabId, changeInfo, tab) => { - if (changeInfo.status === "complete") { - console.log("Tab finished loading:", tabId, tab.url); - } +await createOrUpdateContextMenu("save-selection", { + title: "Save selection", + contexts: ["selection"], }); -// Later, to stop listening: -off(); +const off = onContextMenusClicked(async (info, tab) => { + if (info.menuItemId !== "save-selection" || !tab?.id) { + return; + } + + await sendTabMessage(tab.id, { + type: "save-selection", + text: info.selectionText, + }); +}); ``` +`createOrUpdateContextMenu()` avoids duplicate-menu errors during extension reloads, and `sendTabMessage()` keeps the background-to-content-script path promise-based. + +## Helpers + +- [browserDetection](docs/browserDetection.md) — Best-effort browser detection with `BrowserName`, `BrowserFamily`, `guessBrowser()`, `isBrowser()`, and `isBrowserFamily()`. + ## Utilities In addition to Chrome API wrappers, this package provides a set of low-level utilities for error handling, promise management, and listener safety. While these are primarily used internally, they are also exported via the `@addon-core/browser/utils` subpath for advanced usage. For a complete list of utility functions and examples, see the [Utilities Documentation](docs/utils.md). - ## Not yet covered These commonly used WebExtensions/Chrome Extension APIs are not wrapped here yet (Chrome OS–only APIs are intentionally omitted). If you’d like to contribute, please see [CONTRIBUTING.md](CONTRIBUTING.md) and open an issue/PR. @@ -132,8 +151,6 @@ These commonly used WebExtensions/Chrome Extension APIs are not wrapped here yet - devtools.* (inspectedWindow, network, panels) - dns - fontSettings -- identity -- identityProvider - omnibox - pageCapture - platformKeys @@ -148,4 +165,4 @@ These commonly used WebExtensions/Chrome Extension APIs are not wrapped here yet - tabGroups - topSites - tts -- ttsEngine \ No newline at end of file +- ttsEngine diff --git a/docs/browserDetection.md b/docs/browserDetection.md new file mode 100644 index 0000000..9a9d7a3 --- /dev/null +++ b/docs/browserDetection.md @@ -0,0 +1,114 @@ +# browserDetection + +Best-effort browser detection for extension contexts. + +Chrome does not expose a native `chrome.runtime.getBrowserInfo()` API. This helper combines available browser signals and returns where the guess came from, so callers can decide how much they trust the result. + +## Methods + +- [guessBrowser()](#guessBrowser) +- [isBrowser(guess, ...names)](#isBrowser) +- [isBrowserFamily(guess, family)](#isBrowserFamily) + +## Enums + +- `BrowserName` +- `BrowserFamily` +- `BrowserGuessSource` + +--- + + + +### guessBrowser + +```ts +guessBrowser(): Promise +``` + +Guesses the current browser using the best signal available in the current context. + +```ts +import {BrowserName, guessBrowser, isBrowser} from "@addon-core/browser"; + +const guess = await guessBrowser(); + +if (isBrowser(guess, BrowserName.Firefox)) { + // Firefox-specific path +} +``` + +The result contains the guessed browser name, browser family, source, and optional raw metadata: + +```ts +interface BrowserGuess { + name: BrowserName; + family: BrowserFamily; + source: BrowserGuessSource; + rawName?: string; + vendor?: string; + version?: string; +} +``` + +Detection order: + +1. Firefox `runtime.getBrowserInfo()`. +2. Chromium User-Agent Client Hints via `navigator.userAgentData`. +3. Brave-specific `navigator.brave`. +4. Browser-specific globals such as Opera or Safari globals. +5. Generic Chromium User-Agent Client Hints. +6. `navigator.userAgent`. +7. Extension URL protocol from `runtime.getURL("")`. + +When no signal is available, the helper returns: + +```ts +{ + name: BrowserName.Unknown, + family: BrowserFamily.Unknown, + source: BrowserGuessSource.Unknown +} +``` + +For capability checks, prefer feature detection. Use `guessBrowser()` when product logic, diagnostics, or analytics truly need a browser label. + + + +### isBrowser + +```ts +isBrowser(guess: BrowserGuess, ...names: BrowserName[]): boolean +``` + +Checks whether a browser guess matches one of the provided names. + +```ts +import {BrowserName, guessBrowser, isBrowser} from "@addon-core/browser"; + +const guess = await guessBrowser(); + +if (isBrowser(guess, BrowserName.Edge, BrowserName.Chrome)) { + // Chromium browser branch for Chrome or Edge +} +``` + + + +### isBrowserFamily + +```ts +isBrowserFamily(guess: BrowserGuess, family: BrowserFamily): boolean +``` + +Checks whether a browser guess belongs to a browser family. + +```ts +import {BrowserFamily, guessBrowser, isBrowserFamily} from "@addon-core/browser"; + +const guess = await guessBrowser(); + +if (isBrowserFamily(guess, BrowserFamily.Chromium)) { + // Chromium-family branch +} +``` diff --git a/docs/identity.md b/docs/identity.md new file mode 100644 index 0000000..6284b90 --- /dev/null +++ b/docs/identity.md @@ -0,0 +1,170 @@ +# identity + +Documentation: + +- [Chrome Identity API](https://developer.chrome.com/docs/extensions/reference/api/identity) +- [Firefox identity API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/identity) +- [Microsoft Edge extension API support](https://learn.microsoft.com/en-us/microsoft-edge/extensions/developer-guide/api-support) + +A promise-based wrapper for the WebExtension `identity` API. Chrome exposes the full API surface. Firefox, Edge, and Opera should use the portable OAuth flow with `getIdentityRedirectUrl()` and `launchWebAuthFlow()`. Safari does not support this API. + +## Browser support notes + +- Chrome: supports the full `chrome.identity` API, except the underlying `getAccounts()` API is Chrome Dev channel only. This wrapper uses the callback form for Chrome-style runtimes because it works across Manifest V2 and Manifest V3. +- Firefox: supports the portable `browser.identity.launchWebAuthFlow()` promise API and `getRedirectURL()`. +- Edge: use `launchWebAuthFlow()` for portable OAuth. `getAuthToken()` and the underlying `getAccounts()` API are officially unsupported even if feature detection sees the methods. +- Opera: supports the portable web auth flow in Chromium-based builds. +- Safari: Identity API is not supported. + +## Manifest + +For Chrome OAuth token helpers, add `identity` and the `oauth2` manifest section: + +```json +{ + "permissions": ["identity"], + "oauth2": { + "client_id": "client-id.apps.googleusercontent.com", + "scopes": ["profile", "email"] + } +} +``` + +For profile email data, add `identity.email`: + +```json +{ + "permissions": ["identity", "identity.email"] +} +``` + +Interactive authorization flows should be started from a user action, such as a button click. + +## Methods + +- [getIdentityRedirectUrl(path?)](#getIdentityRedirectUrl) +- [launchWebAuthFlow(details)](#launchWebAuthFlow) +- [getAuthToken(details?)](#getAuthToken) +- [removeCachedAuthToken(details)](#removeCachedAuthToken) +- [clearAllCachedAuthTokens()](#clearAllCachedAuthTokens) +- [getProfileUserInfo(details?)](#getProfileUserInfo) +- [getIdentityAccounts()](#getIdentityAccounts) + +## Events + +- [onIdentitySignInChanged(callback)](#onIdentitySignInChanged) + +--- + + + +### getIdentityRedirectUrl + +``` +getIdentityRedirectUrl(path?: string): string +``` + +Generates the extension redirect URL for an OAuth flow. + +```ts +import {getIdentityRedirectUrl} from "@addon-core/browser"; + +const redirectUrl = getIdentityRedirectUrl("oauth"); +``` + +In Chromium browsers this usually returns a URL like `https://.chromiumapp.org/oauth`. + + + +### launchWebAuthFlow + +``` +launchWebAuthFlow(details: LaunchWebAuthFlowDetails): Promise +``` + +Starts a browser-managed OAuth flow and resolves with the final redirect URL. This is the recommended portable API for Firefox, Edge, Opera, and non-Google providers. + +```ts +import {getIdentityRedirectUrl, launchWebAuthFlow} from "@addon-core/browser"; + +const redirectUrl = getIdentityRedirectUrl("oauth"); +const url = new URL("https://accounts.example.com/oauth/authorize"); +url.searchParams.set("redirect_uri", redirectUrl); + +const responseUrl = await launchWebAuthFlow({ + interactive: true, + url: url.toString(), +}); +``` + +This wrapper uses the callback form in Chrome-style runtimes to avoid callbackless Manifest V2 flows hanging, and the Promise form in Firefox where callbacks are not accepted. + +`LaunchWebAuthFlowDetails` also accepts `redirect_uri` for Firefox. This option is Firefox-only, supported since Firefox 63; loopback redirect URIs are supported since Firefox 86. + + + +### getAuthToken + +``` +getAuthToken(details?: chrome.identity.TokenDetails): Promise +``` + +Gets a Chrome OAuth2 access token using the manifest `oauth2` configuration or the provided scopes. The wrapper always resolves to `{ token, grantedScopes }`, including callback-based Chrome runtimes that return those values as separate callback arguments. + +```ts +import {getAuthToken} from "@addon-core/browser"; + +const {token} = await getAuthToken({interactive: true}); +``` + +This method is Chrome-focused. In Edge, it is officially unsupported even if present. + + + +### removeCachedAuthToken + +``` +removeCachedAuthToken(details: chrome.identity.InvalidTokenDetails): Promise +``` + +Removes an OAuth2 access token from Chrome's token cache. + + + +### clearAllCachedAuthTokens + +``` +clearAllCachedAuthTokens(): Promise +``` + +Clears all cached auth tokens and authorization state managed by the Identity API. + + + +### getProfileUserInfo + +``` +getProfileUserInfo(details?: chrome.identity.ProfileDetails): Promise +``` + +Returns profile email and ID information. Requires the `identity.email` permission; otherwise Chrome returns empty fields. + + + +### getIdentityAccounts + +``` +getIdentityAccounts(): Promise +``` + +Returns accounts present in the Chrome profile. This API is Chrome Dev channel only and should not be used for stable product logic. + + + +### onIdentitySignInChanged + +``` +onIdentitySignInChanged(callback: (account: chrome.identity.AccountInfo, signedIn: boolean) => void): () => void +``` + +Fires when the sign-in state changes for an account in the user's profile. Returns an unsubscribe function. diff --git a/src/browserDetection.test.ts b/src/browserDetection.test.ts new file mode 100644 index 0000000..0e5b7d5 --- /dev/null +++ b/src/browserDetection.test.ts @@ -0,0 +1,179 @@ +import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import { + BrowserFamily, + BrowserGuessSource, + BrowserName, + guessBrowser, + isBrowser, + isBrowserFamily, +} from "./browserDetection"; + +describe("browser detection", () => { + let originalBrowser: any; + let originalChrome: any; + let originalNavigatorDescriptor: PropertyDescriptor | undefined; + let originalOprDescriptor: PropertyDescriptor | undefined; + let originalSafariDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + originalBrowser = globalThis.browser; + originalChrome = globalThis.chrome; + originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + originalOprDescriptor = Object.getOwnPropertyDescriptor(globalThis, "opr"); + originalSafariDescriptor = Object.getOwnPropertyDescriptor(globalThis, "safari"); + + delete (globalThis as any).browser; + delete (globalThis as any).chrome; + delete (globalThis as any).navigator; + delete (globalThis as any).opr; + delete (globalThis as any).safari; + }); + + afterEach(() => { + (globalThis as any).browser = originalBrowser; + globalThis.chrome = originalChrome; + restoreGlobalProperty("navigator", originalNavigatorDescriptor); + restoreGlobalProperty("opr", originalOprDescriptor); + restoreGlobalProperty("safari", originalSafariDescriptor); + jest.resetAllMocks(); + }); + + const setGlobalProperty = (name: string, value: any): void => { + Object.defineProperty(globalThis, name, { + configurable: true, + value, + writable: true, + }); + }; + + const restoreGlobalProperty = (name: string, descriptor: PropertyDescriptor | undefined): void => { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + + return; + } + + delete (globalThis as any)[name]; + }; + + test("guesses Firefox using runtime.getBrowserInfo", async () => { + const getBrowserInfo = jest.fn(() => + Promise.resolve({buildID: "20260708000000", name: "Firefox", vendor: "Mozilla", version: "126.0"}) + ); + (globalThis as any).browser = { + runtime: { + getBrowserInfo, + id: "firefox-extension-id", + }, + }; + + await expect(guessBrowser()).resolves.toEqual({ + family: BrowserFamily.Firefox, + name: BrowserName.Firefox, + rawName: "Firefox", + source: BrowserGuessSource.RuntimeBrowserInfo, + vendor: "Mozilla", + version: "126.0", + }); + expect(getBrowserInfo).toHaveBeenCalledTimes(1); + }); + + test("guesses Edge using userAgentData fullVersionList", async () => { + const getHighEntropyValues = jest.fn((_hints: string[]) => + Promise.resolve({ + fullVersionList: [ + {brand: "Chromium", version: "126.0.0.0"}, + {brand: "Microsoft Edge", version: "126.0.2592.87"}, + ], + }) + ); + setGlobalProperty("navigator", { + userAgentData: { + brands: [ + {brand: "Chromium", version: "126"}, + {brand: "Microsoft Edge", version: "126"}, + ], + getHighEntropyValues, + }, + }); + + await expect(guessBrowser()).resolves.toEqual({ + family: BrowserFamily.Chromium, + name: BrowserName.Edge, + rawName: "Microsoft Edge", + source: BrowserGuessSource.UserAgentData, + version: "126.0.2592.87", + }); + expect(getHighEntropyValues).toHaveBeenCalledWith(["fullVersionList"]); + }); + + test("guesses Brave before generic Chromium brands", async () => { + const isBrave = jest.fn(() => Promise.resolve(true)); + setGlobalProperty("navigator", { + brave: {isBrave}, + userAgentData: { + brands: [{brand: "Chromium", version: "126"}], + }, + }); + + await expect(guessBrowser()).resolves.toEqual({ + family: BrowserFamily.Chromium, + name: BrowserName.Brave, + source: BrowserGuessSource.NavigatorBrave, + }); + expect(isBrave).toHaveBeenCalledTimes(1); + }); + + test("guesses Edge using navigator.userAgent fallback", async () => { + setGlobalProperty("navigator", { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.87", + }); + + await expect(guessBrowser()).resolves.toMatchObject({ + family: BrowserFamily.Chromium, + name: BrowserName.Edge, + source: BrowserGuessSource.UserAgent, + version: "126.0.2592.87", + }); + }); + + test("falls back to Chromium for chrome-extension urls", async () => { + const getURL = jest.fn((_path: string) => "chrome-extension://extension-id/"); + globalThis.chrome = { + runtime: { + getURL, + id: "extension-id", + }, + } as any; + + await expect(guessBrowser()).resolves.toEqual({ + family: BrowserFamily.Chromium, + name: BrowserName.Chromium, + source: BrowserGuessSource.ExtensionUrl, + }); + expect(getURL).toHaveBeenCalledWith(""); + }); + + test("returns unknown when no browser signals are available", async () => { + await expect(guessBrowser()).resolves.toEqual({ + family: BrowserFamily.Unknown, + name: BrowserName.Unknown, + source: BrowserGuessSource.Unknown, + }); + }); + + test("checks browser names and families", () => { + const guess = { + family: BrowserFamily.Chromium, + name: BrowserName.Edge, + source: BrowserGuessSource.UserAgent, + }; + + expect(isBrowser(guess, BrowserName.Edge)).toBe(true); + expect(isBrowser(guess, BrowserName.Chrome, BrowserName.Edge)).toBe(true); + expect(isBrowser(guess, BrowserName.Firefox)).toBe(false); + expect(isBrowserFamily(guess, BrowserFamily.Chromium)).toBe(true); + expect(isBrowserFamily(guess, BrowserFamily.Firefox)).toBe(false); + }); +}); diff --git a/src/browserDetection.ts b/src/browserDetection.ts new file mode 100644 index 0000000..c856482 --- /dev/null +++ b/src/browserDetection.ts @@ -0,0 +1,345 @@ +import {browser} from "./browser"; +import type {FirefoxRuntime} from "./types"; + +export enum BrowserName { + Arc = "arc", + Brave = "brave", + Chrome = "chrome", + Chromium = "chromium", + Edge = "edge", + Firefox = "firefox", + Opera = "opera", + Safari = "safari", + Unknown = "unknown", + Vivaldi = "vivaldi", + Yandex = "yandex", +} + +export enum BrowserFamily { + Chromium = "chromium", + Firefox = "firefox", + Safari = "safari", + Unknown = "unknown", +} + +export enum BrowserGuessSource { + BrowserGlobal = "browserGlobal", + ExtensionUrl = "runtime.getURL", + NavigatorBrave = "navigator.brave", + RuntimeBrowserInfo = "runtime.getBrowserInfo", + Unknown = "unknown", + UserAgent = "navigator.userAgent", + UserAgentData = "navigator.userAgentData", +} + +export interface BrowserGuess { + name: BrowserName; + family: BrowserFamily; + source: BrowserGuessSource; + rawName?: string; + vendor?: string; + version?: string; +} + +interface UserAgentBrand { + brand: string; + version: string; +} + +interface NavigatorUADataLike { + brands?: UserAgentBrand[]; + getHighEntropyValues?: (hints: string[]) => Promise<{ + brands?: UserAgentBrand[]; + fullVersionList?: UserAgentBrand[]; + }>; +} + +type NavigatorWithBrowserHints = Navigator & { + brave?: { + isBrave?: () => boolean | Promise; + }; + userAgentData?: NavigatorUADataLike; +}; + +type GlobalBrowserHints = typeof globalThis & { + opr?: unknown; + safari?: unknown; +}; + +const browserFamilies: Record = { + [BrowserName.Arc]: BrowserFamily.Chromium, + [BrowserName.Brave]: BrowserFamily.Chromium, + [BrowserName.Chrome]: BrowserFamily.Chromium, + [BrowserName.Chromium]: BrowserFamily.Chromium, + [BrowserName.Edge]: BrowserFamily.Chromium, + [BrowserName.Firefox]: BrowserFamily.Firefox, + [BrowserName.Opera]: BrowserFamily.Chromium, + [BrowserName.Safari]: BrowserFamily.Safari, + [BrowserName.Unknown]: BrowserFamily.Unknown, + [BrowserName.Vivaldi]: BrowserFamily.Chromium, + [BrowserName.Yandex]: BrowserFamily.Chromium, +}; + +const createBrowserGuess = ( + name: BrowserName, + source: BrowserGuessSource, + details: Omit = {} +): BrowserGuess => ({ + family: browserFamilies[name], + name, + source, + ...details, +}); + +const getNavigator = (): NavigatorWithBrowserHints | undefined => { + if (typeof navigator === "undefined") { + return undefined; + } + + return navigator as NavigatorWithBrowserHints; +}; + +const normalizeBrowserName = (name: string): BrowserName | undefined => { + const value = name.toLowerCase(); + + if (value.includes("microsoft edge") || /\bedg(?:e|a|ios)?\b/.test(value)) { + return BrowserName.Edge; + } + + if (value.includes("opera") || value.includes("opr")) { + return BrowserName.Opera; + } + + if (value.includes("brave")) { + return BrowserName.Brave; + } + + if (value.includes("vivaldi")) { + return BrowserName.Vivaldi; + } + + if (value.includes("yabrowser") || value.includes("yandex")) { + return BrowserName.Yandex; + } + + if (value.includes("arc")) { + return BrowserName.Arc; + } + + if (value.includes("firefox") || value.includes("fennec")) { + return BrowserName.Firefox; + } + + if (value.includes("safari") && !value.includes("chrome") && !value.includes("chromium")) { + return BrowserName.Safari; + } + + if (value.includes("google chrome") || value === "chrome") { + return BrowserName.Chrome; + } + + if (value.includes("chromium")) { + return BrowserName.Chromium; + } +}; + +const guessFromRuntimeBrowserInfo = async (): Promise => { + try { + const runtime = browser().runtime as unknown as Partial; + + if (typeof runtime.getBrowserInfo !== "function") { + return undefined; + } + + const info = await runtime.getBrowserInfo(); + const name = normalizeBrowserName(info.name) ?? BrowserName.Unknown; + + return createBrowserGuess(name, BrowserGuessSource.RuntimeBrowserInfo, { + rawName: info.name, + vendor: info.vendor, + version: info.version, + }); + } catch { + return undefined; + } +}; + +const getUserAgentBrands = async (navigatorApi: NavigatorWithBrowserHints | undefined): Promise => { + const userAgentData = navigatorApi?.userAgentData; + + if (!userAgentData) { + return []; + } + + try { + const highEntropyValues = await userAgentData.getHighEntropyValues?.(["fullVersionList"]); + + if (highEntropyValues?.fullVersionList?.length) { + return highEntropyValues.fullVersionList; + } + } catch { + // Low entropy brands are still useful when high entropy hints are unavailable. + } + + return userAgentData.brands ?? []; +}; + +const guessFromBrands = (brands: UserAgentBrand[], includeGenericChromium: boolean): BrowserGuess | undefined => { + const priority = [ + BrowserName.Edge, + BrowserName.Opera, + BrowserName.Brave, + BrowserName.Vivaldi, + BrowserName.Yandex, + BrowserName.Arc, + BrowserName.Chrome, + BrowserName.Safari, + BrowserName.Firefox, + ]; + + if (includeGenericChromium) { + priority.push(BrowserName.Chromium); + } + + for (const name of priority) { + const brand = brands.find(item => normalizeBrowserName(item.brand) === name); + + if (brand) { + return createBrowserGuess(name, BrowserGuessSource.UserAgentData, { + rawName: brand.brand, + version: brand.version, + }); + } + } +}; + +const guessFromBraveNavigator = async ( + navigatorApi: NavigatorWithBrowserHints | undefined +): Promise => { + const brave = navigatorApi?.brave; + const isBrave = brave?.isBrave; + + if (typeof isBrave !== "function") { + return undefined; + } + + try { + if (await isBrave.call(brave)) { + return createBrowserGuess(BrowserName.Brave, BrowserGuessSource.NavigatorBrave); + } + } catch { + return undefined; + } +}; + +const guessFromBrowserGlobals = (): BrowserGuess | undefined => { + const globalBrowserHints = globalThis as GlobalBrowserHints; + + if (typeof globalBrowserHints.opr !== "undefined") { + return createBrowserGuess(BrowserName.Opera, BrowserGuessSource.BrowserGlobal); + } + + if (typeof globalBrowserHints.safari !== "undefined") { + return createBrowserGuess(BrowserName.Safari, BrowserGuessSource.BrowserGlobal); + } +}; + +const guessFromUserAgent = (userAgent: string | undefined): BrowserGuess | undefined => { + if (!userAgent) { + return undefined; + } + + const patterns: Array<[BrowserName, RegExp]> = [ + [BrowserName.Edge, /\bEdg(?:A|iOS)?\/([\d.]+)/], + [BrowserName.Opera, /\b(?:OPR|Opera)\/([\d.]+)/], + [BrowserName.Vivaldi, /\bVivaldi\/([\d.]+)/], + [BrowserName.Yandex, /\bYaBrowser\/([\d.]+)/], + [BrowserName.Arc, /\bArc\/([\d.]+)/], + [BrowserName.Firefox, /\b(?:Firefox|FxiOS)\/([\d.]+)/], + [BrowserName.Chrome, /\b(?:Chrome|CriOS)\/([\d.]+)/], + [BrowserName.Chromium, /\bChromium\/([\d.]+)/], + [BrowserName.Safari, /\bVersion\/([\d.]+).*Safari\//], + ]; + + for (const [name, pattern] of patterns) { + const match = userAgent.match(pattern); + + if (match) { + return createBrowserGuess(name, BrowserGuessSource.UserAgent, { + version: match[1], + }); + } + } +}; + +const guessFromExtensionUrl = (): BrowserGuess | undefined => { + try { + const protocol = new URL(browser().runtime.getURL("")).protocol; + + if (protocol === "moz-extension:") { + return createBrowserGuess(BrowserName.Firefox, BrowserGuessSource.ExtensionUrl); + } + + if (protocol === "safari-extension:" || protocol === "safari-web-extension:") { + return createBrowserGuess(BrowserName.Safari, BrowserGuessSource.ExtensionUrl); + } + + if (protocol === "chrome-extension:") { + return createBrowserGuess(BrowserName.Chromium, BrowserGuessSource.ExtensionUrl); + } + } catch { + return undefined; + } +}; + +export const guessBrowser = async (): Promise => { + const runtimeInfo = await guessFromRuntimeBrowserInfo(); + + if (runtimeInfo) { + return runtimeInfo; + } + + const navigatorApi = getNavigator(); + const brands = await getUserAgentBrands(navigatorApi); + const specificBrand = guessFromBrands(brands, false); + + if (specificBrand) { + return specificBrand; + } + + const braveInfo = await guessFromBraveNavigator(navigatorApi); + + if (braveInfo) { + return braveInfo; + } + + const browserGlobalInfo = guessFromBrowserGlobals(); + + if (browserGlobalInfo) { + return browserGlobalInfo; + } + + const genericBrand = guessFromBrands(brands, true); + + if (genericBrand) { + return genericBrand; + } + + const userAgentInfo = guessFromUserAgent(navigatorApi?.userAgent); + + if (userAgentInfo) { + return userAgentInfo; + } + + const extensionUrlInfo = guessFromExtensionUrl(); + + if (extensionUrlInfo) { + return extensionUrlInfo; + } + + return createBrowserGuess(BrowserName.Unknown, BrowserGuessSource.Unknown); +}; + +export const isBrowser = (guess: BrowserGuess, ...names: BrowserName[]): boolean => names.includes(guess.name); + +export const isBrowserFamily = (guess: BrowserGuess, family: BrowserFamily): boolean => guess.family === family; diff --git a/src/identity.test.ts b/src/identity.test.ts new file mode 100644 index 0000000..e44481e --- /dev/null +++ b/src/identity.test.ts @@ -0,0 +1,269 @@ +import {afterEach, beforeEach, describe, expect, jest, test} from "@jest/globals"; +import { + clearAllCachedAuthTokens, + getAuthToken, + getIdentityAccounts, + getIdentityRedirectUrl, + getProfileUserInfo, + launchWebAuthFlow, + onIdentitySignInChanged, + removeCachedAuthToken, +} from "./identity"; + +describe("identity", () => { + let originalBrowser: any; + let originalChrome: any; + let originalNavigatorDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + originalBrowser = globalThis.browser; + originalChrome = globalThis.chrome; + originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + + delete (globalThis as any).browser; + delete (globalThis as any).chrome; + delete (globalThis as any).navigator; + }); + + afterEach(() => { + (globalThis as any).browser = originalBrowser; + globalThis.chrome = originalChrome; + restoreGlobalProperty("navigator", originalNavigatorDescriptor); + jest.resetAllMocks(); + }); + + const setChromeIdentity = ( + identity: Partial, + lastError?: chrome.runtime.LastError, + manifestVersion: 2 | 3 = 3 + ): void => { + globalThis.chrome = { + identity, + runtime: { + getManifest: jest.fn(() => ({manifest_version: manifestVersion})), + id: "chrome-extension-id", + lastError, + }, + } as any; + }; + + const createEvent = () => { + const addListener = jest.fn(); + const removeListener = jest.fn(); + + return {addListener, removeListener}; + }; + + const setNavigator = (navigator: Partial): void => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: navigator, + writable: true, + }); + }; + + const restoreGlobalProperty = (name: string, descriptor: PropertyDescriptor | undefined): void => { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + + return; + } + + delete (globalThis as any)[name]; + }; + + test("should generate a redirect url", () => { + const getRedirectURL = jest.fn((path?: string) => `https://chrome-extension-id.chromiumapp.org/${path}`); + setChromeIdentity({getRedirectURL}); + + expect(getIdentityRedirectUrl("oauth")).toBe("https://chrome-extension-id.chromiumapp.org/oauth"); + expect(getRedirectURL).toHaveBeenCalledWith("oauth"); + }); + + test("should launch a Chrome MV2 callback web auth flow", async () => { + const launchWebAuthFlowMock = jest.fn( + (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) => + cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123") + ); + setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 2); + + await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe( + "https://chrome-extension-id.chromiumapp.org/oauth?code=123" + ); + expect(launchWebAuthFlowMock).toHaveBeenCalledWith( + {url: "https://accounts.example/oauth", interactive: true}, + expect.any(Function) + ); + }); + + test("should launch a Chrome MV3 callback web auth flow", async () => { + const launchWebAuthFlowMock = jest.fn( + (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) => + cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123") + ); + setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 3); + + await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe( + "https://chrome-extension-id.chromiumapp.org/oauth?code=123" + ); + expect(launchWebAuthFlowMock).toHaveBeenCalledWith( + {url: "https://accounts.example/oauth", interactive: true}, + expect.any(Function) + ); + }); + + test("should not use Firefox promise-only flow from a user agent fallback", async () => { + const launchWebAuthFlowMock = jest.fn( + (_details: chrome.identity.WebAuthFlowDetails, cb: (url: string) => void) => + cb("https://chrome-extension-id.chromiumapp.org/oauth?code=123") + ); + setChromeIdentity({launchWebAuthFlow: launchWebAuthFlowMock as any}, undefined, 3); + setNavigator({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", + }); + + await expect(launchWebAuthFlow({url: "https://accounts.example/oauth", interactive: true})).resolves.toBe( + "https://chrome-extension-id.chromiumapp.org/oauth?code=123" + ); + expect(launchWebAuthFlowMock).toHaveBeenCalledWith( + {url: "https://accounts.example/oauth", interactive: true}, + expect.any(Function) + ); + }); + + test("should launch a Firefox promise-only web auth flow without callback", async () => { + const getBrowserInfo = jest.fn(() => + Promise.resolve({buildID: "1", name: "Firefox", vendor: "Mozilla", version: "86"}) + ); + const launchWebAuthFlowMock = jest.fn((_details: any) => + Promise.resolve("https://extension-id.extensions.allizom.org/oauth?code=123") + ); + (globalThis as any).browser = { + identity: { + launchWebAuthFlow: launchWebAuthFlowMock, + }, + runtime: { + getBrowserInfo, + getManifest: jest.fn(() => ({manifest_version: 2})), + id: "firefox-extension-id", + lastError: undefined, + }, + }; + + await expect( + launchWebAuthFlow({ + redirect_uri: "https://extension-id.extensions.allizom.org/oauth", + url: "https://accounts.example/oauth", + }) + ).resolves.toBe("https://extension-id.extensions.allizom.org/oauth?code=123"); + expect(launchWebAuthFlowMock).toHaveBeenCalledWith({ + redirect_uri: "https://extension-id.extensions.allizom.org/oauth", + url: "https://accounts.example/oauth", + }); + expect(getBrowserInfo).toHaveBeenCalledTimes(1); + }); + + test("should reject launchWebAuthFlow when the browser promise rejects", async () => { + const errorMessage = "Authorization flow failed"; + setChromeIdentity({ + launchWebAuthFlow: jest.fn((_details: chrome.identity.WebAuthFlowDetails) => + Promise.reject(new Error(errorMessage)) + ), + } as any); + + await expect(launchWebAuthFlow({url: "https://accounts.example/oauth"})).rejects.toThrow(errorMessage); + }); + + test("should normalize getAuthToken callback token and granted scopes", async () => { + const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => + cb("access-token", ["email", "profile"]) + ); + setChromeIdentity({getAuthToken: getAuthTokenMock as any}); + + await expect(getAuthToken({interactive: true})).resolves.toEqual({ + grantedScopes: ["email", "profile"], + token: "access-token", + }); + expect(getAuthTokenMock).toHaveBeenCalledWith({interactive: true}, expect.any(Function)); + }); + + test("should keep object-style getAuthToken results", async () => { + const result = {grantedScopes: ["email"], token: "access-token"}; + const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(result)); + setChromeIdentity({getAuthToken: getAuthTokenMock as any}); + + await expect(getAuthToken()).resolves.toBe(result); + expect(getAuthTokenMock).toHaveBeenCalledWith({}, expect.any(Function)); + }); + + test("should treat a null getAuthToken callback value as a token result", async () => { + const getAuthTokenMock = jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(null)); + setChromeIdentity({getAuthToken: getAuthTokenMock as any}); + + await expect(getAuthToken()).resolves.toEqual({ + grantedScopes: undefined, + token: null, + }); + }); + + test("should reject getAuthToken when runtime lastError is set", async () => { + const errorMessage = "OAuth token unavailable"; + setChromeIdentity( + { + getAuthToken: jest.fn((_details: chrome.identity.TokenDetails, cb: any) => cb(undefined)), + } as any, + {message: errorMessage} + ); + + await expect(getAuthToken()).rejects.toThrow(errorMessage); + }); + + test("should remove a cached auth token", async () => { + const removeCachedAuthTokenMock = jest.fn((_details: chrome.identity.InvalidTokenDetails, cb: () => void) => + cb() + ); + setChromeIdentity({removeCachedAuthToken: removeCachedAuthTokenMock as any}); + + await expect(removeCachedAuthToken({token: "access-token"})).resolves.toBeUndefined(); + expect(removeCachedAuthTokenMock).toHaveBeenCalledWith({token: "access-token"}, expect.any(Function)); + }); + + test("should clear all cached auth tokens", async () => { + const clearAllCachedAuthTokensMock = jest.fn((cb: () => void) => cb()); + setChromeIdentity({clearAllCachedAuthTokens: clearAllCachedAuthTokensMock as any}); + + await expect(clearAllCachedAuthTokens()).resolves.toBeUndefined(); + expect(clearAllCachedAuthTokensMock).toHaveBeenCalledWith(expect.any(Function)); + }); + + test("should get profile user info", async () => { + const profile = {email: "user@example.com", id: "gaia-id"}; + const getProfileUserInfoMock = jest.fn((_details: chrome.identity.ProfileDetails, cb: any) => cb(profile)); + setChromeIdentity({getProfileUserInfo: getProfileUserInfoMock as any}); + + await expect(getProfileUserInfo({accountStatus: "ANY"})).resolves.toBe(profile); + expect(getProfileUserInfoMock).toHaveBeenCalledWith({accountStatus: "ANY"}, expect.any(Function)); + }); + + test("should get identity accounts", async () => { + const accounts = [{id: "account-id"}]; + const getAccountsMock = jest.fn((cb: any) => cb(accounts)); + setChromeIdentity({getAccounts: getAccountsMock as any}); + + await expect(getIdentityAccounts()).resolves.toBe(accounts); + expect(getAccountsMock).toHaveBeenCalledWith(expect.any(Function)); + }); + + test("should subscribe to sign-in changes and unsubscribe", () => { + const onSignInChangedEvent = createEvent(); + setChromeIdentity({onSignInChanged: onSignInChangedEvent as any}); + const callback = jest.fn(); + + const unsubscribe = onIdentitySignInChanged(callback); + + expect(onSignInChangedEvent.addListener).toHaveBeenCalledWith(expect.any(Function)); + const listener = onSignInChangedEvent.addListener.mock.calls[0][0]; + unsubscribe(); + expect(onSignInChangedEvent.removeListener).toHaveBeenCalledWith(listener); + }); +}); diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000..cf98f17 --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,95 @@ +import {browser} from "./browser"; +import {BrowserGuessSource, BrowserName, guessBrowser, isBrowser} from "./browserDetection"; +import {callWithPromise, checkLastError, handleListener} from "./utils"; + +type AccountInfo = chrome.identity.AccountInfo; +type GetAuthTokenResult = chrome.identity.GetAuthTokenResult; +type InvalidTokenDetails = chrome.identity.InvalidTokenDetails; +type ProfileDetails = chrome.identity.ProfileDetails; +type ProfileUserInfo = chrome.identity.ProfileUserInfo; +type TokenDetails = chrome.identity.TokenDetails; + +export interface LaunchWebAuthFlowDetails extends chrome.identity.WebAuthFlowDetails { + /** + * Firefox-only redirect URI override. Supported in Firefox 63+; loopback redirect URIs are supported in Firefox 86+. + */ + redirect_uri?: string; +} + +type IdentityApi = typeof chrome.identity; +type GetAuthTokenCallback = (token?: string | GetAuthTokenResult | null, grantedScopes?: string[]) => void; + +const identity = (): IdentityApi => browser().identity; + +// Methods +export const getIdentityRedirectUrl = (path?: string): string => identity().getRedirectURL(path); + +export const launchWebAuthFlow = async (details: LaunchWebAuthFlowDetails): Promise => { + const browserGuess = await guessBrowser(); + const isFirefoxRuntime = + isBrowser(browserGuess, BrowserName.Firefox) && browserGuess.source === BrowserGuessSource.RuntimeBrowserInfo; + + return callWithPromise(cb => { + if (isFirefoxRuntime) { + return identity().launchWebAuthFlow(details); + } + + return identity().launchWebAuthFlow(details, cb); + }); +}; + +export const getAuthToken = (details?: TokenDetails): Promise => { + return new Promise((resolve, reject) => { + const getToken = identity().getAuthToken as unknown as ( + details: TokenDetails, + callback: GetAuthTokenCallback + ) => undefined | Promise; + + const callback: GetAuthTokenCallback = (token, grantedScopes) => { + try { + checkLastError(); + + if (token !== null && typeof token === "object") { + resolve(token); + + return; + } + + resolve({token, grantedScopes} as GetAuthTokenResult); + } catch (e) { + reject(e); + } + }; + + try { + const result = getToken(details || {}, callback); + + if (result && typeof result.then === "function") { + result.then(resolve, reject); + } + } catch (e) { + reject(e); + } + }); +}; + +export const removeCachedAuthToken = (details: InvalidTokenDetails): Promise => + callWithPromise(cb => identity().removeCachedAuthToken(details, cb)); + +export const clearAllCachedAuthTokens = (): Promise => + callWithPromise(cb => identity().clearAllCachedAuthTokens(cb)); + +export const getProfileUserInfo = (details?: ProfileDetails): Promise => + callWithPromise(cb => identity().getProfileUserInfo(details || {}, cb)); + +/** + * Chrome Dev channel only. Do not build stable product logic on this API. + */ +export const getIdentityAccounts = (): Promise => callWithPromise(cb => identity().getAccounts(cb)); + +// Events +export const onIdentitySignInChanged = ( + callback: Parameters[0] +): (() => void) => { + return handleListener(identity().onSignInChanged, callback); +}; diff --git a/src/index.ts b/src/index.ts index 3b5a4b4..83e5d99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from "./action"; export * from "./alarms"; export * from "./audio"; export * from "./browser"; +export * from "./browserDetection"; export * from "./browsingData"; export * from "./commands"; export * from "./contextMenus"; @@ -12,6 +13,7 @@ export * from "./env"; export * from "./extension"; export * from "./history"; export * from "./i18n"; +export * from "./identity"; export * from "./idle"; export * from "./management"; export * from "./notifications";