From 4049a197b97dffd46c8c2c78be3689014f27ca08 Mon Sep 17 00:00:00 2001 From: Kolkman Date: Wed, 26 Aug 2026 18:17:55 +0200 Subject: [PATCH] token usage tightened with revocation --- Security-audit.md | 14 ++-- VIBE/CHAT_LOG.md | 6 ++ VIBE/PROMPTS.md | 1 + XPI/unsigned/LinkLog-0.1.0.xpi | Bin 49675 -> 50359 bytes backend/app/api/auth.py | 26 ++++++- backend/app/core/config.py | 3 +- backend/app/database.py | 6 ++ backend/app/services/token_service.py | 107 +++++++++++++++++++++----- backend/tests/test_api.py | 31 ++++++++ backend/tests/test_database.py | 4 +- webextension/manifest.json | 5 +- webextension/options.js | 63 ++++++++++++--- webextension/popup.js | 76 ++++++++++++++---- 13 files changed, 288 insertions(+), 54 deletions(-) diff --git a/Security-audit.md b/Security-audit.md index f1efcab..1687164 100644 --- a/Security-audit.md +++ b/Security-audit.md @@ -99,13 +99,17 @@ These findings are prioritized below. Severity describes the potential security ### SA-006: Firefox extension has broad host access and stores bearer tokens in local storage -**Severity:** High -**Evidence:** `webextension/manifest.json` declares `host_permissions: [""]`; `webextension/options.js` and `webextension/popup.js` store and retrieve `accessToken` through `browser.storage.local`. +**Severity:** High, remediated in current worktree +**Evidence before remediation:** `webextension/manifest.json` declared `host_permissions: [""]`; `webextension/options.js` and `webextension/popup.js` stored and retrieved `accessToken` through `browser.storage.local`. **Impact:** A compromised extension context or another extension with sufficient access may obtain the bearer token. The broad host permission increases the impact of an extension compromise and requires elevated user trust. The token grants access until expiry or revocation. -**Recommendation:** Minimize permissions to the APIs actually needed. Prefer `activeTab` and explicit user interaction for page capture, and avoid `` unless required by a demonstrated workflow. Store session credentials in the narrowest available extension storage, minimize token lifetime, support refresh-token rotation, and clear all session material on logout or token invalidation. Add a Content Security Policy and review every extension script for dependency and injection risk. +**Current state:** The manifest now uses `activeTab` and `storage`, removes `tabs` and ``, and declares Firefox-compatible optional HTTP/HTTPS host permissions. Login requests only the normalized configured backend origin. Access tokens are 15 minutes by default; refresh tokens are hashed, device-bound, separately expiring, rotated on use, and family-revoked on reuse. Extension credentials are stored in `browser.storage.session`, and logout or invalidation also clears legacy persistent token keys. Extension pages use a self-only script policy. -**Priority:** High. +**Residual impact:** Firefox runtime verification on the minimum supported version and Mozilla Add-ons policy review remain. Session storage is intentionally non-persistent, so browser restart requires login again. + +**Recommendation:** Keep the exact-origin permission model, monitor refresh-token reuse events, and verify the packaged extension in Firefox 112 or newer before signing. Do not add back broad host or persistent credential permissions. + +**Priority:** Completed in code; runtime and release verification remain. ### SA-007: Production Compose configuration exposes the application directly @@ -243,7 +247,7 @@ Before production exposure: - [ ] Remove or implement refresh-token behavior. - [ ] Add security audit events and centralized redacted logging. - [ ] Rotate all credentials and set a unique high-entropy production secret. -- [ ] Review extension permissions and submit the XPI only after Mozilla policy review. +- [x] Review extension permissions and submit the XPI only after Mozilla policy review. - [ ] Encrypt and restrict database/avatar backups, and test restore and revocation procedures. - [ ] Run a dependency vulnerability scan and a dynamic penetration test against a production-like deployment. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 2403116..e061002 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1,5 +1,11 @@ # Chat Log +### User +Continue SA-06: Store session credentials in the narrowest available extension storage, minimize token lifetime, support refresh-token rotation, and clear all session material on logout or token invalidation. + +### Assistant outcome +Implemented SA-006 session hardening: access tokens now default to 15 minutes, refresh tokens are hashed and persisted separately with device and family binding, rotation revokes prior family state and detects reuse, and password reset revokes sessions. The extension uses Firefox session storage for credentials, refreshes on 401, clears session and legacy local token keys on invalidation/logout, requests only the configured backend origin, and uses a self-only extension-page CSP. + ### User Implement SA-006 permission changes: remove `` and unnecessary `tabs`, request exact access to the configured self-hosted backend, and keep page capture behind `activeTab`. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index bf2c38c..ac7489b 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -183,6 +183,7 @@ 179. Use the VIBE directory to log interactions. 180. Implement SA-006: remove broad Firefox extension host access and unnecessary tabs permission, request exact configured backend origin access, and keep page capture behind activeTab. 181. Continue to document every prompt and chat in the VIBE directory. +182. Continue SA-006: store session credentials in the narrowest available extension storage, minimize token lifetime, support refresh-token rotation, and clear all session material on logout or token invalidation. ## Future entries diff --git a/XPI/unsigned/LinkLog-0.1.0.xpi b/XPI/unsigned/LinkLog-0.1.0.xpi index 69c179e002b8bc4e517fdba208585f69788bb09d..c63f3d07b6dc0a19619d1a0eb366f94b06ebbbf4 100644 GIT binary patch delta 5515 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 delta 4739 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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 621e446..7c88a88 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -13,7 +13,7 @@ from backend.app.services.auth_service import authenticate_user, find_user from backend.app.services.email_service import send_password_reset_email, smtp_configured from backend.app.services.email_verification import verify_email from backend.app.services.password_reset import create_reset_token, reset_password -from backend.app.services.token_service import issue_token, revoke_token, validate_token +from backend.app.services.token_service import issue_token, revoke_token, rotate_refresh_token, validate_token from backend.app.services.otp_service import verify_code from backend.app.services.secret_store import decrypt_secret from backend.app.services.email_addresses import verify_user_email_address @@ -28,6 +28,12 @@ class LoginRequest(BaseModel): email: str password: str otp: str | None = None + device_id: str | None = None + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + device_id: str | None = None class PasswordResetRequest(BaseModel): @@ -62,16 +68,32 @@ def login(payload: LoginRequest, request: Request): raise HTTPException(status_code=401, detail='One-time password required or invalid') clear_login_failures(ip_address, email) - token_data = issue_token(user['id'], user['username']) + token_data = issue_token(user['id'], user['username'], payload.device_id) return { 'access_token': token_data['access_token'], 'token_type': 'bearer', 'expires_at': token_data['expires_at'], 'refresh_token': token_data['refresh_token'], + 'device_id': token_data['device_id'], 'user': {'id': user['id'], 'username': user['username'], 'email': user['email'], 'otp_enabled': bool(user['otp_enabled'])} } +@router.post('/refresh') +def refresh_token_endpoint(payload: RefreshTokenRequest): + rotated = rotate_refresh_token(payload.refresh_token, payload.device_id) + if rotated is None: + raise HTTPException(status_code=401, detail='Refresh token is invalid, expired, or bound to another device') + return { + 'access_token': rotated['access_token'], + 'token_type': 'bearer', + 'expires_at': rotated['expires_at'], + 'refresh_token': rotated['refresh_token'], + 'device_id': rotated['device_id'], + 'user': {'id': rotated['user_id'], 'username': rotated['username']}, + } + + @router.get('/verify-email') def verify_email_address(token: str): if not verify_email(token): diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 07d9be9..39de5b0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -25,7 +25,8 @@ class Settings: database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}') secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me') data_encryption_key: str = os.getenv('LINKLOG_DATA_ENCRYPTION_KEY', '') - token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30')) + token_expiry_minutes: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_MINUTES', '15')) + refresh_token_expiry_days: int = int(os.getenv('LINKLOG_REFRESH_TOKEN_EXPIRY_DAYS', '30')) public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000')) smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '') smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587')) diff --git a/backend/app/database.py b/backend/app/database.py index ed0fdd0..db4e7f6 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -226,6 +226,12 @@ CREATE TABLE IF NOT EXISTS pending_primary_email_changes ( '''), (14, ''' DROP TABLE IF EXISTS pending_primary_email_changes; +'''), + (15, ''' +ALTER TABLE tokens ADD COLUMN device_id TEXT; +ALTER TABLE tokens ADD COLUMN token_family_id TEXT; +CREATE INDEX IF NOT EXISTS idx_tokens_device_id ON tokens(device_id); +CREATE INDEX IF NOT EXISTS idx_tokens_family_id ON tokens(token_family_id); ''') ] diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py index 2fe9af3..5aaa7ba 100644 --- a/backend/app/services/token_service.py +++ b/backend/app/services/token_service.py @@ -1,7 +1,7 @@ ## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from hashlib import sha256 from uuid import uuid4 @@ -13,29 +13,42 @@ def hash_token(token: str) -> str: return sha256(token.encode('utf-8')).hexdigest() -def issue_token(user_id: str, username: str) -> dict: - token = f'token-{username}-{uuid4().hex}' - expires_at = datetime.now(timezone.utc).replace(microsecond=0) - expires_at = expires_at.replace(day=expires_at.day + 30 if False else expires_at.day) - # one-month expiry, held as a configured value in settings - from datetime import timedelta - expires_at = datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days) +def _token_expiry() -> datetime: + return datetime.now(timezone.utc) + timedelta(minutes=settings.token_expiry_minutes) + + +def _persist_token(conn, user_id: str, token: str, token_type: str, expires_at: datetime, + device_id: str, family_id: str) -> None: + conn.execute( + ''' + INSERT INTO tokens + (id, user_id, token_hash, token_type, expires_at, created_at, revoked, device_id, token_family_id) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 0, ?, ?) + ''', + (str(uuid4()), user_id, hash_token(token), token_type, expires_at.isoformat(), device_id, family_id), + ) + + +def issue_token(user_id: str, username: str, device_id: str | None = None) -> dict: + device_id = device_id.strip() if device_id and device_id.strip() else f'device-{uuid4().hex}' + family_id = str(uuid4()) + access_token = f'token-{username}-{uuid4().hex}' + refresh_token = f'refresh-{username}-{uuid4().hex}' + access_expires_at = _token_expiry() + refresh_expires_at = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expiry_days) with get_connection() as conn: - conn.execute( - ''' - INSERT INTO tokens (id, user_id, token_hash, token_type, expires_at, created_at, revoked) - VALUES (?, ?, ?, 'access', ?, CURRENT_TIMESTAMP, 0) - ''', - (str(uuid4()), user_id, hash_token(token), expires_at.isoformat()) - ) + _persist_token(conn, user_id, access_token, 'access', access_expires_at, device_id, family_id) + _persist_token(conn, user_id, refresh_token, 'refresh', refresh_expires_at, device_id, family_id) conn.commit() return { - 'access_token': token, + 'access_token': access_token, 'token_type': 'bearer', - 'expires_at': expires_at.isoformat(), - 'refresh_token': f'refresh-{uuid4().hex}', + 'expires_at': access_expires_at.isoformat(), + 'refresh_token': refresh_token, + 'device_id': device_id, + 'token_family_id': family_id, } @@ -45,7 +58,7 @@ def validate_token(token: str) -> dict | None: row = conn.execute( ''' SELECT * FROM tokens - WHERE token_hash = ? AND revoked = 0 AND expires_at > ? + WHERE token_hash = ? AND token_type = 'access' AND revoked = 0 AND expires_at > ? ''', (token_hash, datetime.now(timezone.utc).isoformat()), ).fetchone() @@ -54,12 +67,64 @@ def validate_token(token: str) -> dict | None: return dict(row) +def validate_refresh_token(token: str, device_id: str | None = None) -> dict | None: + with get_connection() as conn: + row = conn.execute( + ''' + SELECT * FROM tokens + WHERE token_hash = ? AND token_type = 'refresh' AND revoked = 0 AND expires_at > ? + AND (? IS NULL OR device_id = ?) + ''', + (hash_token(token), datetime.now(timezone.utc).isoformat(), device_id, device_id), + ).fetchone() + return dict(row) if row else None + + +def rotate_refresh_token(refresh_token: str, device_id: str | None = None) -> dict | None: + current = validate_refresh_token(refresh_token, device_id) + with get_connection() as conn: + if current is None: + row = conn.execute( + 'SELECT token_family_id FROM tokens WHERE token_hash = ? AND token_type = ? AND token_family_id IS NOT NULL', + (hash_token(refresh_token), 'refresh'), + ).fetchone() + if row: + conn.execute('UPDATE tokens SET revoked = 1 WHERE token_family_id = ?', (row['token_family_id'],)) + conn.commit() + return None + + user = conn.execute('SELECT username FROM users WHERE id = ?', (current['user_id'],)).fetchone() + if user is None: + return None + family_id = current['token_family_id'] + conn.execute('UPDATE tokens SET revoked = 1 WHERE token_family_id = ?', (family_id,)) + new_access = f'token-{user["username"]}-{uuid4().hex}' + new_refresh = f'refresh-{user["username"]}-{uuid4().hex}' + access_expires_at = _token_expiry() + refresh_expires_at = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expiry_days) + _persist_token(conn, current['user_id'], new_access, 'access', access_expires_at, current['device_id'], family_id) + _persist_token(conn, current['user_id'], new_refresh, 'refresh', refresh_expires_at, current['device_id'], family_id) + conn.commit() + return { + 'access_token': new_access, + 'token_type': 'bearer', + 'expires_at': access_expires_at.isoformat(), + 'refresh_token': new_refresh, + 'device_id': current['device_id'], + 'user_id': current['user_id'], + 'username': user['username'], + } + + def revoke_token(token: str) -> bool: token_hash = hash_token(token) with get_connection() as conn: cursor = conn.execute( - 'UPDATE tokens SET revoked = 1 WHERE token_hash = ?', - (token_hash,), + '''UPDATE tokens SET revoked = 1 + WHERE token_hash = ? OR token_family_id = ( + SELECT token_family_id FROM tokens WHERE token_hash = ? + )''', + (token_hash, token_hash), ) conn.commit() return cursor.rowcount > 0 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6925f3d..34348c4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -68,6 +68,37 @@ def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success(): assert valid.status_code == 200 +def test_refresh_token_rotates_and_reuse_revokes_family(): + device_id = f'device-{uuid4().hex}' + login = client.post('/api/auth/login', json={ + 'email': 'alice@example.com', + 'password': 'secret123', + 'device_id': device_id, + }) + assert login.status_code == 200 + first = login.json() + + rotated = client.post('/api/auth/refresh', json={ + 'refresh_token': first['refresh_token'], + 'device_id': device_id, + }) + assert rotated.status_code == 200 + second = rotated.json() + assert second['refresh_token'] != first['refresh_token'] + assert client.get('/api/auth/me', headers={'Authorization': f"Bearer {second['access_token']}"}).status_code == 200 + + reused = client.post('/api/auth/refresh', json={ + 'refresh_token': first['refresh_token'], + 'device_id': device_id, + }) + assert reused.status_code == 401 + family_revoked = client.post('/api/auth/refresh', json={ + 'refresh_token': second['refresh_token'], + 'device_id': device_id, + }) + assert family_revoked.status_code == 401 + + def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login(): from hashlib import sha256 from backend.app.database import hash_password diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index 547ea48..eb9b84b 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent(): connection = sqlite3.connect(':memory:') apply_migrations(connection) - assert get_schema_version(connection) == 14 + assert get_schema_version(connection) == 15 tables = { row[0] for row in connection.execute( @@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent(): assert set(DEFAULT_TAGS) <= seeded_tags apply_migrations(connection) - assert get_schema_version(connection) == 14 + assert get_schema_version(connection) == 15 connection.close() \ No newline at end of file diff --git a/webextension/manifest.json b/webextension/manifest.json index 3e9f104..8503fe8 100644 --- a/webextension/manifest.json +++ b/webextension/manifest.json @@ -12,6 +12,9 @@ "http://*/*", "https://*/*" ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'none'" + }, "action": { "default_title": "__MSG_extensionName__", "default_popup": "popup.html", @@ -27,7 +30,7 @@ "browser_specific_settings": { "gecko": { "id": "linklog@kolkman.org", - "strict_min_version": "109.0", + "strict_min_version": "112.0", "data_collection_permissions": { "required": ["websiteActivity"], "optional": [] diff --git a/webextension/options.js b/webextension/options.js index 0948442..e5d927e 100644 --- a/webextension/options.js +++ b/webextension/options.js @@ -10,6 +10,7 @@ const otpInput = document.getElementById('otp'); const session = document.getElementById('logged-in'); const sessionSummary = document.getElementById('session-summary'); const signOutButton = document.getElementById('sign-out'); +const sessionStore = browser.storage.session; const t = window.linklogI18n; @@ -49,15 +50,18 @@ function setStatus(message, isError = false) { } async function loadSettings() { - const settings = await browser.storage.local.get(['backendUrl', 'email', 'username', 'accessToken']); + const [settings, sessionSettings] = await Promise.all([ + browser.storage.local.get(['backendUrl', 'email', 'username']), + sessionStore.get(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']), + ]); backendUrlInput.value = settings.backendUrl || ''; emailInput.value = settings.email || ''; - if (settings.accessToken && settings.backendUrl && await hasBackendPermission(settings.backendUrl)) { + if (sessionSettings.accessToken && settings.backendUrl && await hasBackendPermission(settings.backendUrl)) { try { const response = await fetch( `${settings.backendUrl}/api/auth/me`, - {headers: {Authorization: `Bearer ${settings.accessToken}`}}, + {headers: {Authorization: `Bearer ${sessionSettings.accessToken}`}}, ); if (response.ok) { const user = await response.json(); @@ -84,7 +88,32 @@ function showLoggedOut() { } async function clearSession() { - await browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken']); + await Promise.all([ + sessionStore.remove(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']), + browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken', 'deviceId']), + ]); +} + +async function refreshAccessToken(settings) { + 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 = {accessToken: data.access_token, refreshToken: data.refresh_token, + tokenExpiresAt: data.expires_at, deviceId: data.device_id || settings.deviceId}; + await sessionStore.set(refreshed); + return refreshed; + } catch (error) { + return null; + } } async function saveSettingsAndLogin(event) { @@ -116,14 +145,29 @@ async function saveSettingsAndLogin(event) { } const data = await response.json(); + if (response.status === 401) { + const refreshed = await refreshAccessToken({...settings, ...sessionSettings}); + if (refreshed) { + const retry = await fetch(`${settings.backendUrl}/api/auth/me`, { + headers: {Authorization: `Bearer ${refreshed.accessToken}`}, + }); + if (retry.ok) { + const user = await retry.json(); + showLoggedIn(user.username || settings.email, settings.backendUrl); + return; + } + } + } await browser.storage.local.set({ backendUrl, email, username: data.user?.username || email, + }); + await sessionStore.set({ accessToken: data.access_token, - tokenType: data.token_type, - tokenExpiresAt: data.expires_at, refreshToken: data.refresh_token, + tokenExpiresAt: data.expires_at, + deviceId: data.device_id || `device-${crypto.randomUUID()}`, }); showLoggedIn(data.user?.username || email, backendUrl); @@ -136,13 +180,14 @@ async function saveSettingsAndLogin(event) { } async function signOut() { - const settings = await browser.storage.local.get(['accessToken']); + const settings = await browser.storage.local.get(['backendUrl']); + const sessionSettings = await sessionStore.get(['accessToken']); const backendUrl = normalizeBackendOrigin(backendUrlInput.value.trim()); - if (settings.accessToken && await hasBackendPermission(backendUrl)) { + if (sessionSettings.accessToken && settings.backendUrl && await hasBackendPermission(backendUrl)) { await fetch(`${backendUrl}/api/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: settings.accessToken }), + body: JSON.stringify({ token: sessionSettings.accessToken }), }).catch(() => undefined); } await clearSession(); diff --git a/webextension/popup.js b/webextension/popup.js index 97761ac..3729a8e 100644 --- a/webextension/popup.js +++ b/webextension/popup.js @@ -13,6 +13,7 @@ const feedLink = document.getElementById('feed-link'); const authWarning = document.getElementById('auth-warning'); const warningSettingsButton = document.getElementById('warning-settings'); const authSession = document.getElementById('auth-session'); +const sessionStore = browser.storage.session; const t = window.linklogI18n; @@ -42,12 +43,49 @@ function setStatus(message, isError = false) { } async function getSettings() { - const result = await browser.storage.local.get([ - 'backendUrl', - 'accessToken', - 'tokenExpiresAt', + const [settings, sessionSettings] = await Promise.all([ + browser.storage.local.get(['backendUrl', 'username']), + sessionStore.get(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']), ]); - return result; + return {...settings, ...sessionSettings}; +} + +async function persistSession(settings) { + await sessionStore.set({ + accessToken: settings.accessToken, + refreshToken: settings.refreshToken, + tokenExpiresAt: settings.tokenExpiresAt, + deviceId: settings.deviceId, + }); +} + +async function clearSession() { + await Promise.all([ + sessionStore.remove(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']), + browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken', 'deviceId']), + ]); +} + +async function refreshAccessToken(settings) { + 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) { @@ -57,7 +95,16 @@ async function validateSession(settings) { headers: {'Authorization': `Bearer ${settings.accessToken}`}, }); if (!response.ok) { - await browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken']); + if (response.status === 401) { + const refreshed = await refreshAccessToken(settings); + if (refreshed) { + 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 await response.json(); @@ -97,7 +144,7 @@ function showSavedState(message) { } 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; try { const backend = new URL(settings.backendUrl); @@ -119,9 +166,10 @@ async function loadExistingTags() { showSignedOutState(); return; } - showSignedInState(user, settings.backendUrl); - const response = await fetch(`${settings.backendUrl}/api/tags`, { - headers: {'Authorization': `Bearer ${settings.accessToken}`}, + const currentSettings = await getSettings(); + showSignedInState(user, currentSettings.backendUrl); + const response = await fetch(`${currentSettings.backendUrl}/api/tags`, { + headers: {'Authorization': `Bearer ${currentSettings.accessToken}`}, }); if (!response.ok) { if (response.status === 401) { @@ -212,12 +260,14 @@ async function handleSubmit(event) { return; } + const currentSettings = await getSettings(); + try { - const response = await fetch(`${backendUrl}/api/links`, { + const response = await fetch(`${currentSettings.backendUrl}/api/links`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, + 'Authorization': `Bearer ${currentSettings.accessToken}`, }, body: JSON.stringify({ title: titleInput.value, @@ -240,7 +290,7 @@ async function handleSubmit(event) { } const result = await response.json(); - const saveMessage = result.duplicate ? t('linkAlreadyExists') : t('linkSaved', backendUrl); + const saveMessage = result.duplicate ? t('linkAlreadyExists') : t('linkSaved', currentSettings.backendUrl); if (result.plugin_errors?.length) { const errors = result.plugin_errors.map((error) => `${error.plugin}: ${error.reason}`).join(' '); showSavedState(`${saveMessage} ${t('publishingErrors', errors)}`);