26 октября 2023

Разработка браузерного расширения с OAuth 2.0 авторизацией

9 минут

Несмотря на довольно большой объем информации по разработке расширения для браузера которую можно найти в интернете, я решил поделиться личным опытом разработки и рассказать про нюансы работы с OAuth 2.0 авторизацией

Введение

По разработке расширения у Google есть большой и детальный мануал, который по шагам расписывает процесс разработки, показывает структуру расширения и предоставляет массу примеров различных решений и это действительно помогает в разработке даже когда делаешь это первый раз. 

Основной файл расширения, где мы храним всю информацию о нашем расширении и задаем конфигурационные переменные как для функционала расширения так и для браузера — это manifest.json. В самом базовом варианте этот файл выглядит так:

{
 "manifest_version": 3, //Версия API 
 "name": "Hello Extensions", //Название расширения
 "description": "Base Level Extension", //Описание расширения
 "version": "1.0", //Версия расширения
 "action": {
   "default_popup": "hello.html", //Путь к html файлу с окном настроек нашего расширения
   "default_icon": "hello_extensions.png" //Путь к изображению иконки нашего расширения
 }
}

Все выглядит довольно просто, в файле hello.html все верстается так же как и обычная html страница, просто выводится в попапе, на страницу можно подключать любые скрипты и стили (кроме cdn), различные библиотеки типа Jquery и Axios, так что функционал тут довольно обширный.

Единственный момент который обнаружил это то, что никак не повлиять на границу окна расширения, убрать или добавить тени, или сделать закругление углов не выйдет используя данный функционал, но я заметил разработчикам одного популярного расширения удалось найти выход из этой ситуации.

Вместо того чтобы использовать функционал специально созданный для создания окна настроек, они пользуются функционалом взаимодействия с контентом страницы и выводят кастомный попап непосредственно на саму веб страницу в правый верхний угол туда где по идее и должно быть всплывающее окно настроек, таким образом визуально для пользователя нет никакой разницы и разработчики не связаны ограничениями для окна настроек, как по мне довольно оригинальное решение если вам очень нужно кастомизировать настройки.

Вот мы создали папку с проектом, еще нужно создать внутри 2 файла manifest.json и hello.html, теперь чтобы посмотреть что у нас получилось, заходим в браузере по адресу chrome://extensions или вручную через настройки:

После этого включаем режим разработчика, нажимаем “Загрузить распакованное расширение” и указываем путь до папки с нашим расширением:

После чего увидим в списке наше расширение:

Даже можем посмотреть страницу настроек которую прописали в manifest.json:

При внесении изменений в расширение чтобы они отобразились в браузере достаточно нажать кнопку перезагрузки:

Структура кода

Google рекомендуют использовать такую структуру для расширения:

В принципе выглядит все понятно, но я добавил больше разделения по директориям в директории popup:

Авторизация

В нашем случае расширение служит частью полноценного сервиса в котором пользователь имеет личный кабинет, может пополнять баланс и может управлять предоставляемой услугой, поэтому наше расширение должно плотно взаимодействовать с backend на Laravel посредством API. И вот тут начались проблемы.

Первое что нужно было сделать – организовать авторизацию, в интернете про это очень мало информации, кроме того она должна работать по Oauth 2.0 и у API браузера есть функциональность для этого. Вот наиболее понятный пример:

function validate(redirectURL) {
  // validate the access token
}

function authorize() {
  const redirectURL = browser.identity.getRedirectURL();
  const clientID =
    "664583959686-fhvksj46jkd9j5v96vsmvs406jgndmic.apps.googleusercontent.com";
  const scopes = ["openid", "email", "profile"];
  let authURL = "https://accounts.google.com/o/oauth2/auth";
  authURL += `?client_id=${clientID}`;
  authURL += `&response_type=token`;
  authURL += `&redirect_uri=${encodeURIComponent(redirectURL)}`;
  authURL += `&scope=${encodeURIComponent(scopes.join(" "))}`;

  return browser.identity.launchWebAuthFlow({
    interactive: true,
    url: authURL,
  });
}

function getAccessToken() {
  return authorize().then(validate);
}

Перечитав множество страниц с непонятными примерами, стал понятен принцип работы такого взаимодействия:

  1. Расширение формирует url для запроса на наш backend добавляя в него параметр для проверки, что запрос действительно пришел из расширения и параметр с адресом для редиректа после успешной авторизации.
  2. Backend делает валидацию запроса используя переданный для этого специальный параметр и возвращает 401 если валидация не пройдена.
  3. Если валидация пройдена, авторизуем пользователя и формируем токен.
  4. Берем адрес для редиректа сформированный расширением и добавляем к нему GET параметр с нашим токеном (тут можно добавить дополнительно шифрование токена) и делаем редирект на этот адрес.
  5. Функция launchWebAuthFlow, которая инициировала авторизацию в расширении получает URL на который сделал редирект наш Backend и достает из него токен авторизации.

В теории система странная, но рабочая, теперь перейдем к практике.
Я записал все ключи и URL нашего бэкенда в manifest.json, так правильнее и безопаснее, получить значения можно при помощи функции chrome.runtime.getManifest().

Backend

Oauth 2.0 авторизация на Laravel уже реализована в пакете Passport и используя его можно довольно просто ее настроить как уверяет документация, но у нас на проекте уже стоит Sanctum и решили сделать на нем, тем более что с ним есть опыт работы:

  • Создаем Middleware для валидации ключа в запросе, для этого добавляем в массив $routeMiddleware в файле Http/Kernel.php строку 'auth.extension' => \App\Http\Middleware\EnsureKeyIsVerified::class
  • Создаем middleware класс по пути Http/Middleware/EnsureKeyIsVerified.php который выглядит так:
class EnsureKeyIsVerified
{
	/**
	 * Handle an incoming request.
	 *
	 * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
	 */
	public function handle(Request $request, Closure $next): Response
	{
		if (urldecode($request->client_id) !== hash('sha256', env('EXTENSION_KEY'))) {
			return response()->json(['error' => 'Unauthorized'], 401);
		}
		return $next($request);
	}
}
  • Добавляем эндпоинт который будет создавать токен и делать редирект, к которому добавляем 3 middleware проверки — авторизация пользователя, верифицирован ли наш пользователь (подтвержден ли email) и валидация ключа из предыдущего пункта
    Route::middleware(['auth', 'verified', 'auth.extension'])->get('/extension/login', [ExtensionController::class, 'login']);
  • Если все 3 middleware пройдены, вызывается функция login, которая создает токен для пользователя, добавляет его в url, присланный расширением, и делает редирект
public function login( Request $request ) 
{
    $token = auth()->user()->createToken( 'extensionAuthToken' )->plainTextToken;

    return redirect( $request->redirect_uri . '?access_token=' . $token );
}
  • Если же пользователь не авторизован в кабинете в этот момент, то расширение откроет окно авторизации нашего кабинета (за это отвечает middleware auth) и после авторизации в кабинете вызовет функцию login
  • Теперь мы можем создавать наши API маршруты используя стандартную схему работы с Laravel Sanctum
Route::middleware(['auth:sanctum'])->group(function () {
// Список маршрутов
});

Service worker

Наконец, после редиректа с нашего бэкенда функция launchWebAuthFlow должна получить адрес, на который мы сделали редирект, и в этом адресе как раз и хранится наш токен, нам остается только распарсить url и вытащить его (и расшифровать если нужно).

Все это мы делаем в файле background.js который находится в корне проекта и является своеобразным бэкендом нашего расширения. Подключаем этот файл мы так же через manifest.json

"background": {"service_worker": "background.js"}

И вот так выглядит финальный код наше функции авторизации:

function authorize() {
    const manifest = chrome.runtime.getManifest(); //Получаем значения из манифеста
    const redirectURL = chrome.identity.getRedirectURL(); //Формируем адрес для редиректа после успешной авторизации
    let authURL = `${manifest.oauth2.app_url}/extension/login`; //Прописываем адрес нашего бэкэнда


    hashAsync("SHA-256", manifest.oauth2.client_id).then(outputHash => { //Хэшируем ключ
        let key = encodeURIComponent(outputHash);
        authURL += `?client_id=${key}`; // Добавляем ключ в URL
        authURL += `&redirect_uri=${redirectURL}`; // Добавляем адрес редиректа


        chrome.identity.launchWebAuthFlow(
                {'url': authURL, 'interactive': true},
                function (redirect_url) {
                    if (redirect_url) {
                        const url = new URL(redirect_url);
                        const access_token = url.searchParams.get('access_token'); // Парсим полученный URL
                        chrome.storage.local.set({access_token: access_token}).then(() => { // Записываем его в хранилище
                            chrome.runtime.sendMessage({ // Инициируем событие о завершении авторизации
                                message: "end_oauth",
                                token: access_token
                            });
                        });
                    }
                }
        );
    });
}

Как видите после получения токена мы записываем его в хранилище нашего браузера и воспользоваться им можно будет в любом скрипте нашего расширения.

Окно настроек

Теперь вернемся к нашему окну с настройками расширения куда мы планируем вывести данные полученные по API из личного кабинета. Для начала проверяем есть ли в хранилище токен авторизации для запросов к API:


chrome.storage.local.get(["access_token"]).then((result) => {});

Если есть, то показываем, делаем запрос к API и выводим все что хотели, если же нет токена нам нужно показать пользователю сообщение о том, что он еще не авторизован:

При нажатии кнопки «Log in» инициируем событие start_oauth:

document.getElementById("login").addEventListener("click", chrome.runtime.sendMessage({"message": "start_oauth"}));

А в скрипте background.js ловим это событие и запускаем ранее созданную функцию авторизации:

chrome.runtime.onMessage.addListener(
        function (request, sender, sendResponse) {
            if (request.message === "start_oauth") {
                authorize();
            }
        }
)

Доступы и разрешения

Для работы с расширения с различными функциями, буквально на каждую нужно прописывать разрешения в manifest.json и хотелось бы разобрать более детально что именно нужно прописать и для чего:

  • "permissions": ["identity","storage"] — identity позволяет пользоваться Oauth 2.0, а storage — хранить данные в хранилище браузера chrome.storage.local
  • "host_permissions": ["https://google.com/"] — прописываем адрес нашего кабинета и куда мы отправляем запросы на авторизацию и апи
  • content_scripts — позволяет подключать скрипты и стили из расширения (опять же никаких CDN) на определенных страницах
    • "matches": ["https://*.ebay.com/*"] — на каком сайте подключать
    • "css": ["popup/js/lib/fancybox/fancybox.css"] — стили для подключения 
    • "js": ["popup/js/lib/fancybox/fancybox.js"] — скрипты для подключения
"content_scripts": [
    {
        "js": [
            "popup/js/lib/fancybox/fancybox.js",
            "scripts/content.js"
        ],
        "css": [
            "popup/js/lib/fancybox/fancybox.css",
        ],
        "matches": [
            "https://*.ebay.com/*",
        ]
    }
]
  • web_accessible_resources — позволяет загружать на определенный сайт определенные ресурсы, например картинки или скрипты
    • resources — какие ресурсы подключаем
    • matches — на какой сайт
"web_accessible_resources": [
    {
        "resources": [
            "popup/fonts/SegoeUI-Italic.woff2",
            "popup/fonts/SegoeUI.woff2",
            "popup/fonts/SegoeUI-SemiBold.woff2"
        ],
        "matches": [ "https://*.ebay.com/*" ]
    }
]

Обнаружение расширения

Еще один интересный момент которым хотелось бы поделится — это определение установлено ли расширение или нет. Эту фичу нам необходимо было внедрить в кабинет нашего сервиса и для этого нужно:

  • Добавить ID расширения в бэкенд нашего дашборда:
  • Так как в расширениях аналог событий играет роль сообщений, нам нужно на странице нашего кабинета создать сообщение, а в расширении его поймать и ответить, для этого в дашборде добавляем скрипт который будет запрашивать версию нашего расширения:
if(chrome.runtime){
    chrome.runtime.sendMessage("ID расширения", {message: "version"}, function (reply) {
        if (reply) {
            if (reply.version) {
                //Получили версию расширения
            }
        }
    });
}
  • Далее нужно сделать чтобы расширение ответило на запрос страницы для этого добавим слушатель:
chrome.runtime.onMessageExternal.addListener(function(request, sender, sendResponse) {
    if (request) {
        if (request.message) {
            if (request.message == "version") {
                sendResponse({version: 1.0});
            }
        }
    }
    return true;
});

Таким образом можно без проблем организовать взаимодействие вашего расширения с вашим дашбордом в живом режиме.

Заключение

Изначально мне казалось что в разработке расширения нет ничего сложного и принципиально нового для меня, но в итоге оказалось что и тут есть свои подводные камни. Сильно порадовало большое количество официальной документации с примерами по которой на удивление почти все понятно. Отдельно хотел бы отметить максимально странную систему авторизации, может я конечно что то не так понял, но все равно мне кажется стоило сделать прямое взаимодействие расширения и бэкенда. В целом опыт создания расширения оказался очень даже положительным и увлекательным.