token usage tightened with revocation
This commit is contained in:
+9
-5
@@ -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: ["<all_urls>"]`; `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: ["<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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 `<all_urls>` and unnecessary `tabs`, request exact access to the configured self-hosted backend, and keep page capture behind `activeTab`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
+24
-2
@@ -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):
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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);
|
||||
''')
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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": []
|
||||
|
||||
+54
-9
@@ -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();
|
||||
|
||||
+63
-13
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user