BTHLABS-57: Pre-auth page in the extension

This commit is contained in:
Tomek Wójcik 2025-09-16 19:36:15 +00:00
parent 46254730bd
commit 495255206e
9 changed files with 293 additions and 52 deletions

View File

@ -90,3 +90,5 @@ Shared (Extension)/Resources/images/
Shared (Extension)/Resources/background-bundle.js
Shared (Extension)/Resources/content-bundle.js
Shared (Extension)/Resources/manifest.json
Shared (Extension)/Resources/preauth.html
Shared (Extension)/Resources/preauth.js

View File

@ -114,6 +114,8 @@
"Resources/content-bundle.js",
Resources/images,
Resources/manifest.json,
Resources/preauth.html,
Resources/preauth.js,
SafariWebExtensionHandler.m,
);
target = 4CABCAD42E56F0C900D8A354 /* HotPocket Extension (iOS) */;
@ -126,6 +128,8 @@
"Resources/content-bundle.js",
Resources/images,
Resources/manifest.json,
Resources/preauth.html,
Resources/preauth.js,
SafariWebExtensionHandler.m,
);
target = 4CABCADE2E56F0C900D8A354 /* HotPocket Extension (macOS) */;

View File

@ -40,7 +40,9 @@ if (BASE_URL === null) {
const PLUGINS = [
string({
include: /\.html$/,
include: [
'src/content/templates/*.html',
],
}),
replace({
include: /\.js$/,
@ -115,6 +117,13 @@ export default [
src: 'assets/images',
dest: OUTPUT_PATH,
},
{
src: [
'src/content/preauth.html',
'src/content/preauth.js',
],
dest: OUTPUT_PATH,
},
],
}),
],

View File

@ -1,8 +1,23 @@
import HotPocketExtension from '../common';
const AUTH_URL = (new URL('/integrations/extension/authenticate/', HotPocketExtension.base_url)).toString();
const POST_AUTH_URL = (new URL('/integrations/extension/post-authenticate/', HotPocketExtension.base_url)).toString();
const RPC_URL = (new URL('/rpc/', HotPocketExtension.base_url)).toString();
const POST_AUTH_PATH = '/integrations/extension/post-authenticate/';
const RPC_PATH = '/rpc/';
let authSessionToken = null;
let rpcURL = null;
const updateRpcURL = () => {
rpcURL = null;
if (HotPocketExtension.base_url !== null) {
rpcURL = (new URL(RPC_PATH, HotPocketExtension.base_url)).toString();
}
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.updateRpcURL()',
HotPocketExtension.base_url,
rpcURL,
);
};
const makeJSONRPCCall = (method, params) => {
return {
@ -43,7 +58,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
},
);
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.content.executeJSONRPCCall()', response,
'HotPocketExtension.background.executeJSONRPCCall()', response,
);
if (response.status !== 200) {
@ -56,7 +71,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
const callResult = await response.json();
if (callResult.error) {
HotPocketExtension.LOGGER.error(
'HotPocketExtension.content.executeJSONRPCCall(): RPC error',
'HotPocketExtension.background.executeJSONRPCCall(): RPC error',
callResult.error.code,
callResult.error.message,
callResult.error.data,
@ -69,7 +84,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
}
} catch (exception) {
HotPocketExtension.LOGGER.error(
'HotPocketExtension.content.executeJSONRPCCall(): Fetch error', exception,
'HotPocketExtension.background.executeJSONRPCCall(): Fetch error', exception,
);
error = {
code: -32000,
@ -90,9 +105,9 @@ const getAccessTokenMeta = () => {
const doSave = async (accessToken, tab) => {
const call = makeJSONRPCCall('saves.create', [tab.url]);
const [result, error] = await executeJSONRPCCall(RPC_URL, call, {accessToken});
const [result, error] = await executeJSONRPCCall(rpcURL, call, {accessToken});
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.content.doSave():', result, error,
'HotPocketExtension.background.doSave():', result, error,
);
if (error !== null) {
@ -112,7 +127,7 @@ const doCreateAndStoreAccessToken = async (authKey) => {
);
const [accessToken, error] = await executeJSONRPCCall(
RPC_URL, accessTokenCall, {accessToken: null},
rpcURL, accessTokenCall, {accessToken: null},
);
if (error === null) {
@ -125,15 +140,34 @@ const doCreateAndStoreAccessToken = async (authKey) => {
};
const doHandleAuthFlow = (authTab) => {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.doHandleAuthFlow()', authTab,
);
let currentAuthTabId = authTab.id;
return new Promise((resolve, reject) => {
const onTabsUpdated = (tabId, changeInfo, updatedTab) => {
if (tabId === authTab.id) {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.content.doHandleAuthFlow.onTabsUpdated()', updatedTab, changeInfo,
);
const changedURL = changeInfo.url;
const changedURL = changeInfo.url;
if (changedURL && changedURL.startsWith(POST_AUTH_URL)) {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.doHandleAuthFlow.onTabsUpdated()',
updatedTab,
changedURL,
(changedURL && changedURL.includes(POST_AUTH_PATH)),
changeInfo,
);
const expectedSessionTabQuery = `?authSessionToken=${authSessionToken}`;
if (tabId !== currentAuthTabId && changedURL.includes(expectedSessionTabQuery)) {
// When redirecting from the preauth page to the HotPocket instance,
// Safari "replaces" the auth tab with a new one. This nasty hack will
// allow the extension to keep track of it.
// I hate computers.
currentAuthTabId = tabId;
}
if (tabId === currentAuthTabId) {
if (changedURL && changedURL.includes(POST_AUTH_PATH)) {
const parsedChangedURL = new URL(changedURL);
const authKey = parsedChangedURL.searchParams.get('auth_key');
@ -154,8 +188,9 @@ const doHandleAuthFlow = (authTab) => {
reject(error);
}).
finally(() => {
authSessionToken = null;
HotPocketExtension.api.tabs.onUpdated.removeListener(onTabsUpdated);
HotPocketExtension.api.tabs.remove(authTab.id);
HotPocketExtension.api.tabs.remove(currentAuthTabId);
});
}
}
@ -175,7 +210,7 @@ const doCheckAuth = async (accessToken) => {
[accessToken, getAccessTokenMeta()],
);
const [result, error] = await executeJSONRPCCall(RPC_URL, call, {
const [result, error] = await executeJSONRPCCall(rpcURL, call, {
accessToken,
});
@ -196,15 +231,28 @@ const doCheckAuth = async (accessToken) => {
return accessToken;
};
const doGetAccessToken = async () => {
let storageResult = await HotPocketExtension.api.storage.local.get('accessToken');
let accessToken = await doCheckAuth(
storageResult.accessToken || null,
const doSetupRPC = async () => {
let storageResult = await HotPocketExtension.api.storage.local.get(
['accessToken', 'baseURL'],
);
let accessToken = null;
if (storageResult.baseURL) {
HotPocketExtension.base_url = storageResult.baseURL;
updateRpcURL();
accessToken = await doCheckAuth(
storageResult.accessToken || null,
);
}
if (accessToken === null) {
authSessionToken = crypto.randomUUID();
const authTab = await HotPocketExtension.api.tabs.create({
url: AUTH_URL,
url: HotPocketExtension.api.runtime.getURL(
`preauth.html?authSessionToken=${authSessionToken}`,
),
});
accessToken = await doHandleAuthFlow(authTab);
@ -217,24 +265,53 @@ const doSendTabMessage = (tab, message) => {
HotPocketExtension.api.tabs.sendMessage(tab.id, message).
then((result) => {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.content.doSendTabMessage(): message sent', message, result,
'HotPocketExtension.background.doSendTabMessage(): message sent',
message,
result,
);
}).
catch((error) => {
HotPocketExtension.LOGGER.error(
'HotPocketExtension.content.doSendTabMessage(): could not send message', error,
'HotPocketExtension.background.doSendTabMessage(): could not send message',
error,
);
});
};
const doUpdateBaseURL = (nextBaseURL) => {
HotPocketExtension.base_url = nextBaseURL;
updateRpcURL();
HotPocketExtension.api.storage.local.
set({
baseURL: nextBaseURL,
}).
then(() => {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.doUpdateBaseURL()', 'Base URL saved',
);
}).
catch((error) => {
HotPocketExtension.LOGGER.error(
'HotPocketExtension.background.doUpdateBaseURL()', error,
);
});
};
const onTabCreated = (tab) => {
HotPocketExtension.LOGGER.debug('HotPocketExtension.onTabCreated()', tab);
HotPocketExtension.api.action.enable(tab.id);
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.onTabCreated()', tab,
);
HotPocketExtension.api.action.enable(tab.id).catch((error) => {
HotPocketExtension.LOGGER.error(
'HotPocketExtension.background.onTabCreated()', tab.id, error,
);
});
};
const onBrowserActionClicked = async (tab) => {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.onBrowserActionClicked()', tab.url,
'HotPocketExtension.background.onBrowserActionClicked()', tab.url,
);
if (!tab.url) {
@ -245,11 +322,11 @@ const onBrowserActionClicked = async (tab) => {
let error = null;
try {
let accessToken = await doGetAccessToken();
let accessToken = await doSetupRPC();
result = await doSave(accessToken, tab);
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.onBrowserActionClicked()', result,
'HotPocketExtension.background.onBrowserActionClicked()', result,
);
} catch (exception) {
HotPocketExtension.LOGGER.error(
@ -276,10 +353,11 @@ const onMessage = (message, sender, sendResponse) => {
let response = {ok: true};
try {
if (message.type === 'HotPocket:Extension:ping') {
HotPocketExtension.LOGGER.debug(sender.tab.id);
doSendTabMessage(sender.tab, {
type: 'HotPocket:Extension:pong',
});
} else if (message.type === 'HotPocket:Extension:setBaseURL') {
doUpdateBaseURL(message.result);
}
} catch (exception) {
HotPocketExtension.LOGGER.error(
@ -300,6 +378,8 @@ export default ({...configuration}) => {
background: true,
});
updateRpcURL();
HotPocketExtension.api.tabs.onCreated.addListener(onTabCreated);
HotPocketExtension.api.action.onClicked.addListener(onBrowserActionClicked);

View File

@ -8,7 +8,7 @@ const HotPocketExtension = {
version: __HOTPOCKET_EXTENSION_VERSION__,
debug: DEBUG,
api: null,
base_url: __HOTPOCKET_EXTENSION_BASE_URL__,
base_url: null,
LOGGER: {
// eslint-disable-next-line no-console
debug: (DEBUG === true) ? console.log : noop,

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,45 @@
/*!
* HotPocket by BTHLabs (https://hotpocket.app/)
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(() => {
'use strict';
document.addEventListener('DOMContentLoaded', (event) => {
const form = document.getElementById('PreauthForm');
form.addEventListener('submit', (event) => {
event.stopPropagation();
event.preventDefault();
const inputBaseURL = document.getElementById('id_base_url');
const baseURL = inputBaseURL.value;
let api = window.browser || null;
if (api === null && window.chrome) {
api = window.chrome;
}
api.runtime.sendMessage({
type: 'HotPocket:Extension:setBaseURL',
result: baseURL,
});
const loginURL = new URL('/integrations/extension/authenticate/', inputBaseURL.value);
window.location.replace(loginURL.toString());
return false;
});
});
})();

View File

@ -19,7 +19,7 @@
"data_collection_permissions": {
"required": [
"websiteActivity",
"technicalAndInteraction"
"browsingActivity"
]
}
}

View File

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import base64
import datetime
from invoke import task
from invoke import Context, task
from hotpocket_workspace_tools import get_workspace_mode, WorkspaceMode
@ -63,7 +64,7 @@ WORKSPACE_MODE = get_workspace_mode()
ALL_SERVICES = ['backend', 'packages', 'extension']
def _run_in_service(ctx, service, command, **kwargs):
def _run_in_service(ctx: Context, service, command, **kwargs):
match WORKSPACE_MODE:
case WorkspaceMode.DOCKER:
return ctx.run(
@ -75,7 +76,7 @@ def _run_in_service(ctx, service, command, **kwargs):
ctx.run(f'direnv exec . {command}')
def _get_version(ctx, service):
def _get_version(ctx: Context, service):
with ctx.cd(f'services/{service}'):
run_result = ctx.run('poetry version -s', hide='out')
@ -88,27 +89,27 @@ def _get_head_sha(ctx):
@task
def clean(ctx, service):
def clean(ctx: Context, service):
_run_in_service(ctx, service, 'inv clean')
@task
def test(ctx, service):
def test(ctx: Context, service):
_run_in_service(ctx, service, 'inv test')
@task
def lint(ctx, service):
def lint(ctx: Context, service):
_run_in_service(ctx, service, 'inv lint')
@task
def typecheck(ctx, service):
def typecheck(ctx: Context, service):
_run_in_service(ctx, service, 'inv typecheck')
@task
def shell(ctx, service):
def shell(ctx: Context, service):
assert WORKSPACE_MODE == WorkspaceMode.DOCKER, (
'Just `cd services/{service}` ;)'
)
@ -116,12 +117,21 @@ def shell(ctx, service):
@task
def django_shell(ctx, service):
def django_shell(ctx: Context, service):
_run_in_service(ctx, service, 'inv django-shell')
@task
def build(ctx,
def png_to_data_url(ctx: Context, png_path):
with open(png_path, 'rb') as png_f:
data = png_f.read()
encoded_data = base64.b64encode(data)
print(f'data:image/png;base64,{encoded_data.decode("utf-8")}')
@task
def build(ctx: Context,
service,
context=None,
builder=None,
@ -175,7 +185,7 @@ def build(ctx,
@task
def publish(ctx,
def publish(ctx: Context,
service,
context=None,
target='deployment',
@ -199,12 +209,12 @@ def publish(ctx,
@task
def ci(ctx, service):
def ci(ctx: Context, service):
_run_in_service(ctx, service, 'inv ci')
@task
def setup(ctx, service=None):
def setup(ctx: Context, service=None):
services_to_setup = []
services_to_setup = [*ALL_SERVICES]
@ -216,7 +226,7 @@ def setup(ctx, service=None):
@task
def install(ctx, service=None):
def install(ctx: Context, service=None):
services_to_setup = []
services_to_setup = [*ALL_SERVICES]
@ -228,7 +238,7 @@ def install(ctx, service=None):
@task
def lock(ctx, service):
def lock(ctx: Context, service):
_run_in_service(ctx, service, 'poetry lock --no-update')
@ -238,20 +248,20 @@ def start_cloud(ctx):
@task
def start_web(ctx, service):
def start_web(ctx: Context, service):
_run_in_service(ctx, service, 'inv start-web')
@task
def start_celery_worker(ctx, service):
def start_celery_worker(ctx: Context, service):
_run_in_service(ctx, service, 'inv start-worker')
@task
def start_celery_beat(ctx, service):
def start_celery_beat(ctx: Context, service):
_run_in_service(ctx, service, 'inv start-beat')
@task
def start_app(ctx, service, app):
def start_app(ctx: Context, service, app):
_run_in_service(ctx, service, f'inv start-{app}')