token usage tightened with revocation

This commit is contained in:
2026-08-26 18:17:55 +02:00
parent 1a24d21d0a
commit 4049a197b9
13 changed files with 288 additions and 54 deletions
+9 -5
View File
@@ -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 ### SA-006: Firefox extension has broad host access and stores bearer tokens in local storage
**Severity:** High **Severity:** High, remediated in current worktree
**Evidence:** `webextension/manifest.json` declares `host_permissions: ["<all_urls>"]`; `webextension/options.js` and `webextension/popup.js` store and retrieve `accessToken` through `browser.storage.local`. **Evidence before remediation:** `webextension/manifest.json` declared `host_permissions: ["<all_urls>"]`; `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. **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 `<all_urls>` 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 `<all_urls>`, 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 ### SA-007: Production Compose configuration exposes the application directly
@@ -243,7 +247,7 @@ Before production exposure:
- [ ] Remove or implement refresh-token behavior. - [ ] Remove or implement refresh-token behavior.
- [ ] Add security audit events and centralized redacted logging. - [ ] Add security audit events and centralized redacted logging.
- [ ] Rotate all credentials and set a unique high-entropy production secret. - [ ] 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. - [ ] 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. - [ ] Run a dependency vulnerability scan and a dynamic penetration test against a production-like deployment.
+6
View File
@@ -1,5 +1,11 @@
# Chat Log # 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 ### User
Implement SA-006 permission changes: remove `<all_urls>` and unnecessary `tabs`, request exact access to the configured self-hosted backend, and keep page capture behind `activeTab`. Implement SA-006 permission changes: remove `<all_urls>` and unnecessary `tabs`, request exact access to the configured self-hosted backend, and keep page capture behind `activeTab`.
+1
View File
@@ -183,6 +183,7 @@
179. Use the VIBE directory to log interactions. 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. 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. 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 ## Future entries
Binary file not shown.
+24 -2
View File
@@ -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_service import send_password_reset_email, smtp_configured
from backend.app.services.email_verification import verify_email 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.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.otp_service import verify_code
from backend.app.services.secret_store import decrypt_secret from backend.app.services.secret_store import decrypt_secret
from backend.app.services.email_addresses import verify_user_email_address from backend.app.services.email_addresses import verify_user_email_address
@@ -28,6 +28,12 @@ class LoginRequest(BaseModel):
email: str email: str
password: str password: str
otp: str | None = None otp: str | None = None
device_id: str | None = None
class RefreshTokenRequest(BaseModel):
refresh_token: str
device_id: str | None = None
class PasswordResetRequest(BaseModel): 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') raise HTTPException(status_code=401, detail='One-time password required or invalid')
clear_login_failures(ip_address, email) 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 { return {
'access_token': token_data['access_token'], 'access_token': token_data['access_token'],
'token_type': 'bearer', 'token_type': 'bearer',
'expires_at': token_data['expires_at'], 'expires_at': token_data['expires_at'],
'refresh_token': token_data['refresh_token'], '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'])} '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') @router.get('/verify-email')
def verify_email_address(token: str): def verify_email_address(token: str):
if not verify_email(token): if not verify_email(token):
+2 -1
View File
@@ -25,7 +25,8 @@ class Settings:
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}') 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') secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me')
data_encryption_key: str = os.getenv('LINKLOG_DATA_ENCRYPTION_KEY', '') 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')) public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000'))
smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '') smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '')
smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587')) smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587'))
+6
View File
@@ -226,6 +226,12 @@ CREATE TABLE IF NOT EXISTS pending_primary_email_changes (
'''), '''),
(14, ''' (14, '''
DROP TABLE IF EXISTS pending_primary_email_changes; 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);
''') ''')
] ]
+86 -21
View File
@@ -1,7 +1,7 @@
## Copyright © 2026 Olaf Kolkman ## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later ## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from hashlib import sha256 from hashlib import sha256
from uuid import uuid4 from uuid import uuid4
@@ -13,29 +13,42 @@ def hash_token(token: str) -> str:
return sha256(token.encode('utf-8')).hexdigest() return sha256(token.encode('utf-8')).hexdigest()
def issue_token(user_id: str, username: str) -> dict: def _token_expiry() -> datetime:
token = f'token-{username}-{uuid4().hex}' return datetime.now(timezone.utc) + timedelta(minutes=settings.token_expiry_minutes)
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 def _persist_token(conn, user_id: str, token: str, token_type: str, expires_at: datetime,
from datetime import timedelta device_id: str, family_id: str) -> None:
expires_at = datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days) 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: with get_connection() as conn:
conn.execute( _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)
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())
)
conn.commit() conn.commit()
return { return {
'access_token': token, 'access_token': access_token,
'token_type': 'bearer', 'token_type': 'bearer',
'expires_at': expires_at.isoformat(), 'expires_at': access_expires_at.isoformat(),
'refresh_token': f'refresh-{uuid4().hex}', '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( row = conn.execute(
''' '''
SELECT * FROM tokens 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()), (token_hash, datetime.now(timezone.utc).isoformat()),
).fetchone() ).fetchone()
@@ -54,12 +67,64 @@ def validate_token(token: str) -> dict | None:
return dict(row) 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: def revoke_token(token: str) -> bool:
token_hash = hash_token(token) token_hash = hash_token(token)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.execute( cursor = conn.execute(
'UPDATE tokens SET revoked = 1 WHERE token_hash = ?', '''UPDATE tokens SET revoked = 1
(token_hash,), WHERE token_hash = ? OR token_family_id = (
SELECT token_family_id FROM tokens WHERE token_hash = ?
)''',
(token_hash, token_hash),
) )
conn.commit() conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
+31
View File
@@ -68,6 +68,37 @@ def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success():
assert valid.status_code == 200 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(): def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login():
from hashlib import sha256 from hashlib import sha256
from backend.app.database import hash_password from backend.app.database import hash_password
+2 -2
View File
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
connection = sqlite3.connect(':memory:') connection = sqlite3.connect(':memory:')
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 14 assert get_schema_version(connection) == 15
tables = { tables = {
row[0] row[0]
for row in connection.execute( for row in connection.execute(
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
assert set(DEFAULT_TAGS) <= seeded_tags assert set(DEFAULT_TAGS) <= seeded_tags
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 14 assert get_schema_version(connection) == 15
connection.close() connection.close()
+4 -1
View File
@@ -12,6 +12,9 @@
"http://*/*", "http://*/*",
"https://*/*" "https://*/*"
], ],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none'"
},
"action": { "action": {
"default_title": "__MSG_extensionName__", "default_title": "__MSG_extensionName__",
"default_popup": "popup.html", "default_popup": "popup.html",
@@ -27,7 +30,7 @@
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "linklog@kolkman.org", "id": "linklog@kolkman.org",
"strict_min_version": "109.0", "strict_min_version": "112.0",
"data_collection_permissions": { "data_collection_permissions": {
"required": ["websiteActivity"], "required": ["websiteActivity"],
"optional": [] "optional": []
+54 -9
View File
@@ -10,6 +10,7 @@ const otpInput = document.getElementById('otp');
const session = document.getElementById('logged-in'); const session = document.getElementById('logged-in');
const sessionSummary = document.getElementById('session-summary'); const sessionSummary = document.getElementById('session-summary');
const signOutButton = document.getElementById('sign-out'); const signOutButton = document.getElementById('sign-out');
const sessionStore = browser.storage.session;
const t = window.linklogI18n; const t = window.linklogI18n;
@@ -49,15 +50,18 @@ function setStatus(message, isError = false) {
} }
async function loadSettings() { 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 || ''; backendUrlInput.value = settings.backendUrl || '';
emailInput.value = settings.email || ''; emailInput.value = settings.email || '';
if (settings.accessToken && settings.backendUrl && await hasBackendPermission(settings.backendUrl)) { if (sessionSettings.accessToken && settings.backendUrl && await hasBackendPermission(settings.backendUrl)) {
try { try {
const response = await fetch( const response = await fetch(
`${settings.backendUrl}/api/auth/me`, `${settings.backendUrl}/api/auth/me`,
{headers: {Authorization: `Bearer ${settings.accessToken}`}}, {headers: {Authorization: `Bearer ${sessionSettings.accessToken}`}},
); );
if (response.ok) { if (response.ok) {
const user = await response.json(); const user = await response.json();
@@ -84,7 +88,32 @@ function showLoggedOut() {
} }
async function clearSession() { 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) { async function saveSettingsAndLogin(event) {
@@ -116,14 +145,29 @@ async function saveSettingsAndLogin(event) {
} }
const data = await response.json(); 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({ await browser.storage.local.set({
backendUrl, backendUrl,
email, email,
username: data.user?.username || email, username: data.user?.username || email,
});
await sessionStore.set({
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 || `device-${crypto.randomUUID()}`,
}); });
showLoggedIn(data.user?.username || email, backendUrl); showLoggedIn(data.user?.username || email, backendUrl);
@@ -136,13 +180,14 @@ async function saveSettingsAndLogin(event) {
} }
async function signOut() { 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()); 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`, { await fetch(`${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: sessionSettings.accessToken }),
}).catch(() => undefined); }).catch(() => undefined);
} }
await clearSession(); await clearSession();
+63 -13
View File
@@ -13,6 +13,7 @@ const feedLink = document.getElementById('feed-link');
const authWarning = document.getElementById('auth-warning'); const authWarning = document.getElementById('auth-warning');
const warningSettingsButton = document.getElementById('warning-settings'); const warningSettingsButton = document.getElementById('warning-settings');
const authSession = document.getElementById('auth-session'); const authSession = document.getElementById('auth-session');
const sessionStore = browser.storage.session;
const t = window.linklogI18n; const t = window.linklogI18n;
@@ -42,12 +43,49 @@ function setStatus(message, isError = false) {
} }
async function getSettings() { async function getSettings() {
const result = await browser.storage.local.get([ const [settings, sessionSettings] = await Promise.all([
'backendUrl', browser.storage.local.get(['backendUrl', 'username']),
'accessToken', sessionStore.get(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']),
'tokenExpiresAt',
]); ]);
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) { async function validateSession(settings) {
@@ -57,7 +95,16 @@ async function validateSession(settings) {
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(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 null;
} }
return await response.json(); return await response.json();
@@ -97,7 +144,7 @@ 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.username || !settings.accessToken) return;
try { try {
const backend = new URL(settings.backendUrl); const backend = new URL(settings.backendUrl);
@@ -119,9 +166,10 @@ async function loadExistingTags() {
showSignedOutState(); showSignedOutState();
return; return;
} }
showSignedInState(user, settings.backendUrl); const currentSettings = await getSettings();
const response = await fetch(`${settings.backendUrl}/api/tags`, { showSignedInState(user, currentSettings.backendUrl);
headers: {'Authorization': `Bearer ${settings.accessToken}`}, const response = await fetch(`${currentSettings.backendUrl}/api/tags`, {
headers: {'Authorization': `Bearer ${currentSettings.accessToken}`},
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
@@ -212,12 +260,14 @@ async function handleSubmit(event) {
return; return;
} }
const currentSettings = await getSettings();
try { try {
const response = await fetch(`${backendUrl}/api/links`, { const response = await fetch(`${currentSettings.backendUrl}/api/links`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${currentSettings.accessToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
title: titleInput.value, title: titleInput.value,
@@ -240,7 +290,7 @@ async function handleSubmit(event) {
} }
const result = await response.json(); 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) { if (result.plugin_errors?.length) {
const errors = result.plugin_errors.map((error) => `${error.plugin}: ${error.reason}`).join(' '); const errors = result.plugin_errors.map((error) => `${error.plugin}: ${error.reason}`).join(' ');
showSavedState(`${saveMessage} ${t('publishingErrors', errors)}`); showSavedState(`${saveMessage} ${t('publishingErrors', errors)}`);