plugin updated to use the refresh flow and security tightened
Build LinkLog Development Image / development-image (push) Successful in 9s
Build LinkLog Development Image / development-image (push) Successful in 9s
This commit is contained in:
@@ -1020,5 +1020,11 @@ First implement a proper refresh-token rotation flow with hashing, revocation, a
|
|||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Added a refresh-token rotation flow with hashed token storage, per-device binding, token-family revocation, and a dedicated /api/auth/refresh endpoint. The new flow was validated with a focused regression test covering replay rejection and device mismatch rejection.
|
Added a refresh-token rotation flow with hashed token storage, per-device binding, token-family revocation, and a dedicated /api/auth/refresh endpoint. The new flow was validated with a focused regression test covering replay rejection and device mismatch rejection.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Always update VIBE when finished with a step
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Completed the final extension hardening pass: removed broad host permissions, validated exact backend origins before authenticated requests, kept auth tokens in session storage, cleared invalid-token state aggressively, and verified the extension syntax plus the focused backend auth tests.
|
||||||
|
|
||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Made popup state transitions defensive by synchronizing both `hidden` attributes and CSS classes. Non-authentication tag-loading errors no longer switch the popup to signed-out state; only a rejected session does. Added `display: none !important` guards for both authentication blocks.
|
Made popup state transitions defensive by synchronizing both `hidden` attributes and CSS classes. Non-authentication tag-loading errors no longer switch the popup to signed-out state; only a rejected session does. Added `display: none !important` guards for both authentication blocks.
|
||||||
|
|||||||
@@ -182,6 +182,7 @@
|
|||||||
162. The authenticated session text and sign-in block are still shown together.
|
162. The authenticated session text and sign-in block are still shown together.
|
||||||
163. Use the VIBE directory to log interactions.
|
163. Use the VIBE directory to log interactions.
|
||||||
164. First implement a proper refresh-token rotation flow with hashing, revocation, and device binding.
|
164. First implement a proper refresh-token rotation flow with hashing, revocation, and device binding.
|
||||||
|
165. Always update VIBE when finished with a step.
|
||||||
|
|
||||||
## Future entries
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@
|
|||||||
"storage",
|
"storage",
|
||||||
"tabs"
|
"tabs"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
|
||||||
"<all_urls>"
|
|
||||||
],
|
|
||||||
"action": {
|
"action": {
|
||||||
"default_title": "__MSG_extensionName__",
|
"default_title": "__MSG_extensionName__",
|
||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
|
|||||||
+131
-9
@@ -13,6 +13,35 @@ const signOutButton = document.getElementById('sign-out');
|
|||||||
|
|
||||||
const t = window.linklogI18n;
|
const t = window.linklogI18n;
|
||||||
|
|
||||||
|
function normalizeBackendOrigin(backendUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(backendUrl);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExactBackendPermission(backendUrl) {
|
||||||
|
const origin = normalizeBackendOrigin(backendUrl);
|
||||||
|
if (!origin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasPermission = await browser.permissions.contains({ origins: [`${origin}/*`] });
|
||||||
|
if (hasPermission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return await browser.permissions.request({ origins: [`${origin}/*`] });
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(message, isError = false) {
|
function setStatus(message, isError = false) {
|
||||||
statusEl.textContent = message;
|
statusEl.textContent = message;
|
||||||
statusEl.classList.remove('hidden');
|
statusEl.classList.remove('hidden');
|
||||||
@@ -20,8 +49,76 @@ function setStatus(message, isError = false) {
|
|||||||
statusEl.classList.toggle('success', !isError);
|
statusEl.classList.toggle('success', !isError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStoredSession() {
|
||||||
|
const [localSettings, sessionSettings] = await Promise.all([
|
||||||
|
browser.storage.local.get(['backendUrl', 'email', 'username']),
|
||||||
|
browser.storage.session.get(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backendUrl: localSettings.backendUrl || '',
|
||||||
|
email: localSettings.email || '',
|
||||||
|
username: localSettings.username || '',
|
||||||
|
deviceId: sessionSettings.deviceId || '',
|
||||||
|
accessToken: sessionSettings.accessToken || '',
|
||||||
|
refreshToken: sessionSettings.refreshToken || '',
|
||||||
|
tokenExpiresAt: sessionSettings.tokenExpiresAt || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSession(session) {
|
||||||
|
await browser.storage.local.set({
|
||||||
|
backendUrl: session.backendUrl || '',
|
||||||
|
email: session.email || '',
|
||||||
|
username: session.username || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.storage.session.set({
|
||||||
|
accessToken: session.accessToken || '',
|
||||||
|
refreshToken: session.refreshToken || '',
|
||||||
|
tokenExpiresAt: session.tokenExpiresAt || '',
|
||||||
|
deviceId: session.deviceId || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
const session = await getStoredSession();
|
||||||
|
if (!session.backendUrl || !session.refreshToken || !session.deviceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${session.backendUrl}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
refresh_token: session.refreshToken,
|
||||||
|
device_id: session.deviceId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await clearSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const refreshedSession = {
|
||||||
|
...session,
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
tokenExpiresAt: data.expires_at,
|
||||||
|
deviceId: data.device_id || session.deviceId,
|
||||||
|
};
|
||||||
|
await persistSession(refreshedSession);
|
||||||
|
return refreshedSession;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const settings = await browser.storage.local.get(['backendUrl', 'email', 'username', 'accessToken']);
|
const settings = await getStoredSession();
|
||||||
backendUrlInput.value = settings.backendUrl || '';
|
backendUrlInput.value = settings.backendUrl || '';
|
||||||
emailInput.value = settings.email || '';
|
emailInput.value = settings.email || '';
|
||||||
|
|
||||||
@@ -36,6 +133,20 @@ async function loadSettings() {
|
|||||||
showLoggedIn(user.username || settings.email, settings.backendUrl);
|
showLoggedIn(user.username || settings.email, settings.backendUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (refreshed) {
|
||||||
|
const userResponse = await fetch(
|
||||||
|
`${refreshed.backendUrl}/api/auth/me`,
|
||||||
|
{headers: {Authorization: `Bearer ${refreshed.accessToken}`}},
|
||||||
|
);
|
||||||
|
if (userResponse.ok) {
|
||||||
|
const user = await userResponse.json();
|
||||||
|
showLoggedIn(user.username || refreshed.email, refreshed.backendUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Show the login form when the backend cannot validate the stored token.
|
// Show the login form when the backend cannot validate the stored token.
|
||||||
}
|
}
|
||||||
@@ -56,7 +167,10 @@ function showLoggedOut() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearSession() {
|
async function clearSession() {
|
||||||
await browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken']);
|
await Promise.all([
|
||||||
|
browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken', 'deviceId']),
|
||||||
|
browser.storage.session.remove(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettingsAndLogin(event) {
|
async function saveSettingsAndLogin(event) {
|
||||||
@@ -65,17 +179,25 @@ async function saveSettingsAndLogin(event) {
|
|||||||
const email = emailInput.value.trim();
|
const email = emailInput.value.trim();
|
||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
const otp = otpInput.value.trim();
|
const otp = otpInput.value.trim();
|
||||||
|
const existingSession = await getStoredSession();
|
||||||
|
const deviceId = existingSession.deviceId || crypto.randomUUID();
|
||||||
|
|
||||||
if (!backendUrl || !email || !password) {
|
if (!backendUrl || !email || !password) {
|
||||||
setStatus(t('fillAllFields'), true);
|
setStatus(t('fillAllFields'), true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const origin = normalizeBackendOrigin(backendUrl);
|
||||||
|
if (!origin || !(await ensureExactBackendPermission(backendUrl))) {
|
||||||
|
setStatus(t('unableToLogIn'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${backendUrl}/api/auth/login`, {
|
const response = await fetch(`${backendUrl}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password, otp: otp || null })
|
body: JSON.stringify({ email, password, otp: otp || null, device_id: deviceId })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -83,14 +205,14 @@ async function saveSettingsAndLogin(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
await browser.storage.local.set({
|
await persistSession({
|
||||||
backendUrl,
|
backendUrl,
|
||||||
email,
|
email,
|
||||||
username: data.user?.username || email,
|
username: data.user?.username || email,
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
tokenType: data.token_type,
|
|
||||||
tokenExpiresAt: data.expires_at,
|
|
||||||
refreshToken: data.refresh_token,
|
refreshToken: data.refresh_token,
|
||||||
|
tokenExpiresAt: data.expires_at,
|
||||||
|
deviceId: data.device_id || crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
|
|
||||||
showLoggedIn(data.user?.username || email, backendUrl);
|
showLoggedIn(data.user?.username || email, backendUrl);
|
||||||
@@ -103,9 +225,9 @@ async function saveSettingsAndLogin(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
const settings = await browser.storage.local.get(['accessToken']);
|
const settings = await getStoredSession();
|
||||||
if (settings.accessToken) {
|
if (settings.accessToken && settings.backendUrl) {
|
||||||
await fetch(`${backendUrlInput.value.trim()}/api/auth/logout`, {
|
await fetch(`${settings.backendUrl}/api/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ token: settings.accessToken }),
|
body: JSON.stringify({ token: settings.accessToken }),
|
||||||
|
|||||||
+122
-9
@@ -16,6 +16,35 @@ const authSession = document.getElementById('auth-session');
|
|||||||
|
|
||||||
const t = window.linklogI18n;
|
const t = window.linklogI18n;
|
||||||
|
|
||||||
|
function normalizeBackendOrigin(backendUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(backendUrl);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExactBackendPermission(backendUrl) {
|
||||||
|
const origin = normalizeBackendOrigin(backendUrl);
|
||||||
|
if (!origin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasPermission = await browser.permissions.contains({ origins: [`${origin}/*`] });
|
||||||
|
if (hasPermission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return await browser.permissions.request({ origins: [`${origin}/*`] });
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(message, isError = false) {
|
function setStatus(message, isError = false) {
|
||||||
statusEl.textContent = message;
|
statusEl.textContent = message;
|
||||||
statusEl.classList.remove('hidden');
|
statusEl.classList.remove('hidden');
|
||||||
@@ -24,22 +53,99 @@ function setStatus(message, isError = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getSettings() {
|
async function getSettings() {
|
||||||
const result = await browser.storage.local.get([
|
const [localSettings, sessionSettings] = await Promise.all([
|
||||||
'backendUrl',
|
browser.storage.local.get(['backendUrl']),
|
||||||
'accessToken',
|
browser.storage.session.get(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']),
|
||||||
'tokenExpiresAt',
|
|
||||||
]);
|
]);
|
||||||
return result;
|
|
||||||
|
return {
|
||||||
|
backendUrl: localSettings.backendUrl || '',
|
||||||
|
deviceId: sessionSettings.deviceId || '',
|
||||||
|
accessToken: sessionSettings.accessToken || '',
|
||||||
|
refreshToken: sessionSettings.refreshToken || '',
|
||||||
|
tokenExpiresAt: sessionSettings.tokenExpiresAt || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSession(session) {
|
||||||
|
await browser.storage.local.set({
|
||||||
|
backendUrl: session.backendUrl || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.storage.session.set({
|
||||||
|
accessToken: session.accessToken || '',
|
||||||
|
refreshToken: session.refreshToken || '',
|
||||||
|
tokenExpiresAt: session.tokenExpiresAt || '',
|
||||||
|
deviceId: session.deviceId || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSession() {
|
||||||
|
await Promise.all([
|
||||||
|
browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken', 'deviceId']),
|
||||||
|
browser.storage.session.remove(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings.backendUrl || !settings.refreshToken || !settings.deviceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${settings.backendUrl}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
refresh_token: settings.refreshToken,
|
||||||
|
device_id: settings.deviceId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await clearSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const refreshed = {
|
||||||
|
...settings,
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
tokenExpiresAt: data.expires_at,
|
||||||
|
deviceId: data.device_id || settings.deviceId,
|
||||||
|
};
|
||||||
|
await persistSession(refreshed);
|
||||||
|
return refreshed;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateSession(settings) {
|
async function validateSession(settings) {
|
||||||
if (!settings.backendUrl || !settings.accessToken) return null;
|
if (!settings.backendUrl || !settings.accessToken) return null;
|
||||||
|
if (!(await ensureExactBackendPermission(settings.backendUrl))) {
|
||||||
|
await clearSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${settings.backendUrl}/api/auth/me`, {
|
const response = await fetch(`${settings.backendUrl}/api/auth/me`, {
|
||||||
headers: {'Authorization': `Bearer ${settings.accessToken}`},
|
headers: {'Authorization': `Bearer ${settings.accessToken}`},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken']);
|
if (response.status === 401) {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (refreshed && refreshed.accessToken) {
|
||||||
|
const retry = await fetch(`${refreshed.backendUrl}/api/auth/me`, {
|
||||||
|
headers: {'Authorization': `Bearer ${refreshed.accessToken}`},
|
||||||
|
});
|
||||||
|
if (retry.ok) {
|
||||||
|
return await retry.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await clearSession();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@@ -79,11 +185,12 @@ function showSavedState(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateFeedLink() {
|
async function updateFeedLink() {
|
||||||
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
|
const settings = await getSettings();
|
||||||
if (!settings.backendUrl || !settings.username || !settings.accessToken) return;
|
if (!settings.backendUrl || !settings.accessToken) return;
|
||||||
|
const username = await browser.storage.local.get(['username']);
|
||||||
try {
|
try {
|
||||||
const backend = new URL(settings.backendUrl);
|
const backend = new URL(settings.backendUrl);
|
||||||
feedLink.href = `${backend.origin}/${encodeURIComponent(settings.username)}/`;
|
feedLink.href = `${backend.origin}/${encodeURIComponent(username.username || settings.deviceId || 'user')}/`;
|
||||||
feedLink.classList.remove('hidden');
|
feedLink.classList.remove('hidden');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
feedLink.classList.add('hidden');
|
feedLink.classList.add('hidden');
|
||||||
@@ -181,6 +288,12 @@ async function handleSubmit(event) {
|
|||||||
const token = settings.accessToken;
|
const token = settings.accessToken;
|
||||||
const backendUrl = settings.backendUrl;
|
const backendUrl = settings.backendUrl;
|
||||||
|
|
||||||
|
if (!backendUrl || !(await ensureExactBackendPermission(backendUrl))) {
|
||||||
|
setStatus(t('configureAndLogIn'), true);
|
||||||
|
browser.runtime.openOptionsPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!token || !backendUrl) {
|
if (!token || !backendUrl) {
|
||||||
setStatus(t('configureAndLogIn'), true);
|
setStatus(t('configureAndLogIn'), true);
|
||||||
browser.runtime.openOptionsPage();
|
browser.runtime.openOptionsPage();
|
||||||
|
|||||||
Reference in New Issue
Block a user