fix: harden dashboard auth and RBAC security flows #33

Merged
jimmy merged 1 commits from dev into main 2026-03-09 23:53:52 +08:00
11 changed files with 185 additions and 115 deletions
Showing only changes of commit 4201917e17 - Show all commits
+47 -21
View File
@@ -6,7 +6,7 @@ import tempfile
import jwt
from passlib.context import CryptContext
import pyotp
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordBearer
import config
@@ -14,6 +14,12 @@ import config
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
ACCESS_COOKIE_NAME = "nas_access_token"
REFRESH_COOKIE_NAME = "nas_refresh_token"
COOKIE_SECURE = True
COOKIE_SAMESITE = "lax"
COOKIE_PATH = "/"
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
@@ -26,18 +32,49 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
async def get_current_user(token: str = Depends(oauth2_scheme)):
def _cookie_max_age(minutes: int) -> int:
return max(60, int(minutes * 60))
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
response.set_cookie(
key=ACCESS_COOKIE_NAME,
value=access_token,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path=COOKIE_PATH,
max_age=_cookie_max_age(config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
response.set_cookie(
key=REFRESH_COOKIE_NAME,
value=refresh_token,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path=COOKIE_PATH,
max_age=_cookie_max_age(config.REFRESH_TOKEN_EXPIRE_MINUTES),
)
def clear_auth_cookies(response: Response):
response.delete_cookie(ACCESS_COOKIE_NAME, path=COOKIE_PATH)
response.delete_cookie(REFRESH_COOKIE_NAME, path=COOKIE_PATH)
def user_from_access_token(token: str, include_auth_header: bool = True):
from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None,
)
try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
@@ -49,29 +86,18 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
pages = get_pages(username, role)
sidebar_links = get_sidebar_links(username, role)
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
async def get_current_user(token: str = Depends(oauth2_scheme)):
return user_from_access_token(token)
async def get_current_user_ws(token: str):
from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "access":
raise credentials_exception
username: str = payload.get("sub")
role: str = payload.get("role", "admin")
if username is None:
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
pages = get_pages(username, role)
sidebar_links = get_sidebar_links(username, role)
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
return user_from_access_token(token, include_auth_header=False)
# TOTP Persistence
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
+4 -4
View File
@@ -9,7 +9,7 @@ from slowapi.errors import RateLimitExceeded
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
import auth as auth_module
import config
from rbac import require_page, require_write
from rbac import require_page
import asyncio
import httpx
@@ -160,11 +160,11 @@ app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
# Protected endpoints with page-level RBAC
app.include_router(docker_router.router, prefix="/api/docker",
dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())])
dependencies=[Depends(_inject_user), Depends(require_page("docker"))])
app.include_router(gitea.router, prefix="/api/gitea",
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
app.include_router(files.router, prefix="/api/files",
dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())])
dependencies=[Depends(_inject_user), Depends(require_page("files"))])
app.include_router(system.router, prefix="/api/system",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(litellm.router, prefix="/api/litellm",
@@ -178,7 +178,7 @@ app.include_router(info_engine.router, prefix="/api/info-engine",
app.include_router(security.router, prefix="/api/security",
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
# WebSocket endpoint (auth handled inside handler via Query param)
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
+79 -32
View File
@@ -2,7 +2,8 @@
from datetime import timedelta
import asyncio
import httpx
from fastapi import APIRouter, Depends, HTTPException, status, Request
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
from pydantic import BaseModel
from slowapi import Limiter
from slowapi.util import get_remote_address
@@ -15,11 +16,13 @@ logger = logging.getLogger(__name__)
router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str = ""
class Token(BaseModel):
access_token: str
refresh_token: str
@@ -28,8 +31,46 @@ class Token(BaseModel):
has_2fa: bool
role: str = "admin"
class RefreshRequest(BaseModel):
refresh_token: str
refresh_token: str = ""
def _set_auth_response_tokens(response: Response, access_token: str, refresh_token: str):
auth.set_auth_cookies(response, access_token, refresh_token)
def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str:
cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "")
if cookie_token:
return cookie_token
if req and req.refresh_token:
return req.refresh_token
raise HTTPException(status_code=401, detail="Missing refresh token")
async def _refresh_tokens_from_value(refresh_token_value: str):
try:
payload = jwt.decode(refresh_token_value, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
username = payload.get("sub")
role = payload.get("role", "admin")
if username is None:
raise HTTPException(status_code=401, detail="Invalid user")
if not auth.verify_token_version(payload.get("tv", -1)):
raise HTTPException(status_code=401, detail="Token revoked")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
auth.increment_token_version()
access_token = auth.create_access_token(
data={"sub": username, "role": role},
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
return access_token, refresh_token
async def send_failed_login_alert(ip_address: str, username: str, reason: str):
logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason)
@@ -41,7 +82,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
client_options = {}
if config.TELEGRAM_PROXY:
client_options["proxy"] = config.TELEGRAM_PROXY
async with httpx.AsyncClient(**client_options) as client:
res = await client.post(
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
@@ -52,9 +93,10 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
except Exception as e:
logger.warning("Failed to send Telegram alert: %s", e)
@router.post("/login", response_model=Token)
@limiter.limit("5/minute")
async def login(creds: LoginRequest, request: Request):
async def login(creds: LoginRequest, request: Request, response: Response):
client_ip = request.client.host
# Verify username/password
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()):
@@ -89,6 +131,7 @@ async def login(creds: LoginRequest, request: Request):
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
_set_auth_response_tokens(response, access_token, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
@@ -98,8 +141,9 @@ async def login(creds: LoginRequest, request: Request):
"role": "admin",
}
@router.get("/proxy")
async def proxy_auth(request: Request):
async def proxy_auth(request: Request, response: Response):
"""Auto-login when Authelia has already authenticated the user via forward-auth.
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
from rbac import resolve_role
@@ -125,6 +169,7 @@ async def proxy_auth(request: Request):
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
_set_auth_response_tokens(response, access_token, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
@@ -134,8 +179,9 @@ async def proxy_auth(request: Request):
"role": role,
}
@router.get("/me")
async def read_users_me(current_user = Depends(auth.get_current_user)):
async def read_users_me(current_user=Depends(auth.get_current_user)):
totp_secret = auth.load_totp_secret()
return {
"username": current_user.username,
@@ -145,36 +191,31 @@ async def read_users_me(current_user = Depends(auth.get_current_user)):
"has_2fa": bool(totp_secret),
}
@router.post("/refresh")
@limiter.limit("10/minute")
async def refresh_token(req: RefreshRequest, request: Request):
try:
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
username = payload.get("sub")
role = payload.get("role", "admin")
if username is None:
raise HTTPException(status_code=401, detail="Invalid user")
if not auth.verify_token_version(payload.get("tv", -1)):
raise HTTPException(status_code=401, detail="Token revoked")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
auth.increment_token_version()
access_token = auth.create_access_token(
data={"sub": username, "role": role},
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
refresh_token_value = _extract_refresh_token(request, req)
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
_set_auth_response_tokens(response, access_token, refresh_token)
return {"access_token": access_token, "refresh_token": refresh_token}
@router.post("/logout")
async def logout(response: Response):
auth.increment_token_version()
auth.clear_auth_cookies(response)
return {"ok": True}
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
@limiter.limit("5/minute")
async def change_password(req: ChangePasswordRequest, request: Request, current_user = Depends(auth.get_current_user)):
async def change_password(req: ChangePasswordRequest, request: Request, current_user=Depends(auth.get_current_user)):
if not auth.verify_password(req.current_password, auth.load_password_hash()):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8:
@@ -182,16 +223,18 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_
auth.save_password_hash(auth.get_password_hash(req.new_password))
return {"message": "Password changed successfully"}
# RBAC admin endpoints
@router.get("/rbac/config")
async def rbac_config(current_user = Depends(auth.get_current_user)):
async def rbac_config(current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac
return load_rbac()
@router.put("/rbac/overrides/{username}")
async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)):
async def rbac_set_override(username: str, request: Request, current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac
@@ -208,14 +251,16 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
update_rbac(mutator)
return {"ok": True}
@router.get("/preferences")
async def get_preferences(current_user = Depends(auth.get_current_user)):
async def get_preferences(current_user=Depends(auth.get_current_user)):
from rbac import get_sidebar_order
order = get_sidebar_order(current_user.username)
return {"sidebar_order": order}
@router.put("/preferences")
async def save_preferences(request: Request, current_user = Depends(auth.get_current_user)):
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)):
import traceback
from rbac import save_sidebar_order
try:
@@ -232,8 +277,9 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rbac/overrides/{username}")
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
async def rbac_delete_override(username: str, current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac
@@ -244,8 +290,9 @@ async def rbac_delete_override(username: str, current_user = Depends(auth.get_cu
update_rbac(mutator)
return {"ok": True}
@router.put("/rbac/roles/{role}")
async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)):
async def rbac_update_role(role: str, request: Request, current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac
+3 -2
View File
@@ -1,6 +1,7 @@
import docker
from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, Query
from config import DOCKER_HOST
from rbac import require_admin
router = APIRouter()
client = docker.DockerClient(base_url=DOCKER_HOST)
@@ -21,7 +22,7 @@ def list_containers():
]
@router.post("/containers/{container_id}/{action}")
@router.post("/containers/{container_id}/{action}", dependencies=[Depends(require_admin())])
def container_action(container_id: str, action: str):
if action not in ("start", "stop", "restart"):
return {"error": "invalid action"}
+4 -3
View File
@@ -2,9 +2,10 @@ import os
import re
import shutil
from pathlib import Path
from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from config import VOLUME_ROOT
from rbac import require_admin
router = APIRouter()
BASE = Path(VOLUME_ROOT)
@@ -79,7 +80,7 @@ def download(path: str):
raise HTTPException(status_code=403, detail="Access denied")
@router.post("/upload")
@router.post("/upload", dependencies=[Depends(require_admin())])
async def upload(path: str, file: UploadFile = File(...)):
try:
safe_name = _sanitize_filename(file.filename)
@@ -99,7 +100,7 @@ async def upload(path: str, file: UploadFile = File(...)):
return {"ok": True}
@router.delete("/delete")
@router.delete("/delete", dependencies=[Depends(require_admin())])
def delete(path: str, recursive: bool = False):
try:
target = _safe_path(path)
+3 -2
View File
@@ -2,7 +2,7 @@
import json
import time
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
@@ -112,7 +112,7 @@ async def passkey_login_options(request: Request):
@router.post("/passkey/login/verify")
@limiter.limit("10/minute")
async def passkey_login_verify(request: Request):
async def passkey_login_verify(request: Request, response: Response):
"""Verify passkey authentication and issue tokens."""
body = await request.json()
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
@@ -146,6 +146,7 @@ async def passkey_login_verify(request: Request):
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
auth.set_auth_cookies(response, access_token, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
+4 -3
View File
@@ -6,7 +6,7 @@ from collections import Counter
from fastapi import WebSocket, Query
import asyncssh
import config
from auth import get_current_user_ws
import auth
logger = logging.getLogger(__name__)
@@ -95,12 +95,13 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
await websocket.close(code=1008, reason="Unknown host")
return
if not token:
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or token
if not access_token:
await websocket.close(code=1008, reason="Unauthorized")
return
try:
user = await get_current_user_ws(token)
user = await auth.get_current_user_ws(access_token)
except Exception:
await websocket.close(code=1008, reason="Unauthorized")
return
+12 -7
View File
@@ -14,7 +14,7 @@
import InfoEngine from "./routes/InfoEngine.svelte";
import Login from "./routes/Login.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
const KNOWN_PAGES = new Set([
"dashboard",
@@ -106,11 +106,10 @@
// Try proxy auth first (Authelia forward-auth sets Remote-User header)
try {
const proxyRes = await fetch("/api/auth/proxy");
const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
if (proxyRes.ok) {
const data = await proxyRes.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
authorized = true;
await fetchUserInfo();
const requestedPage = getRequestedPage();
@@ -123,11 +122,13 @@
}
// Regular token auth check
const token = getToken();
if (!token) {
if (!getToken()) {
const refreshed = await tryRefreshSession();
if (!refreshed) {
loading = false;
authorized = false;
return;
}
}
try {
@@ -160,9 +161,13 @@
}
}
function logout() {
async function logout() {
try {
await logoutSession();
} catch (e) {
console.error("Logout failed:", e);
}
setToken("");
setRefreshToken("");
window.location.reload();
}
</script>
+18 -30
View File
@@ -1,5 +1,5 @@
const BASE = "/api";
let token = localStorage.getItem("token") || "";
let token = "";
// Current user info populated after auth
export let currentUser = { username: "", role: "", pages: [] };
@@ -18,33 +18,24 @@ export function getToken() {
return token;
}
export function setRefreshToken(t) {
if (t) localStorage.setItem("refresh_token", t);
else localStorage.removeItem("refresh_token");
}
function getRefreshToken() {
return localStorage.getItem("refresh_token") || "";
}
// Refresh token is cookie-managed server-side.
export function setRefreshToken() {}
let refreshPromise = null;
async function tryRefresh() {
export async function tryRefreshSession() {
if (refreshPromise) return refreshPromise;
const rt = getRefreshToken();
if (!rt) return false;
refreshPromise = (async () => {
try {
const r = await fetch(BASE + "/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: rt }),
credentials: "same-origin",
});
if (!r.ok) return false;
const data = await r.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
return true;
setToken(data.access_token || "");
return !!data.access_token;
} catch {
return false;
} finally {
@@ -54,15 +45,6 @@ async function tryRefresh() {
return refreshPromise;
}
export async function getWebSocketToken(forceRefresh = false) {
if (!forceRefresh && token) return token;
const refreshed = await tryRefresh();
if (refreshed && token) return token;
setToken("");
setRefreshToken("");
return "";
}
async function request(path, opts = {}) {
const headers = opts.headers || {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -73,27 +55,29 @@ async function request(path, opts = {}) {
delete opts.json;
}
const fetchOpts = { ...opts, headers };
if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
try {
let r = await fetch(BASE + path, { ...opts, headers });
let r = await fetch(BASE + path, fetchOpts);
if (r.status === 401 && !path.includes("/auth/refresh")) {
const refreshed = await tryRefresh();
const refreshed = await tryRefreshSession();
if (refreshed) {
headers["Authorization"] = `Bearer ${token}`;
r = await fetch(BASE + path, { ...opts, headers });
r = await fetch(BASE + path, fetchOpts);
}
}
if (r.status === 401) {
setToken("");
setRefreshToken("");
throw new Error("Unauthorized");
}
if (!r.ok) {
const error = new Error(`HTTP ${r.status}`);
error.status = r.status;
try { error.body = await r.json(); } catch { }
try { error.body = await r.json(); } catch {}
throw error;
}
return r.json();
@@ -140,6 +124,10 @@ export function checkAuth() {
return request("/auth/me");
}
export function logout() {
return request("/auth/logout", { method: "POST" });
}
export function getPreferences() {
return get("/auth/preferences");
}
+1 -3
View File
@@ -1,5 +1,5 @@
<script>
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
import { login, setToken } from "../lib/api.js";
import { fade, fly } from "svelte/transition";
let username = $state("");
@@ -56,7 +56,6 @@
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
const data = await res.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
window.location.reload();
} catch (e) {
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
@@ -79,7 +78,6 @@
const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token);
setRefreshToken(res.refresh_token);
// Determine if we need to reload or just update state.
// Reload is safest to clear any stale state.
window.location.reload();
+10 -8
View File
@@ -1,7 +1,7 @@
<script>
import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js";
import { getWebSocketToken } from "../lib/api.js";
import { tryRefreshSession } from "../lib/api.js";
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
@@ -253,14 +253,16 @@
}
async function connect(forceRefresh = false) {
const token = await getWebSocketToken(forceRefresh);
if (!token) {
handleAuthExpired();
return null;
if (forceRefresh) {
const refreshed = await tryRefreshSession();
if (!refreshed) {
handleAuthExpired();
return null;
}
}
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
);
ws.binaryType = "arraybuffer";
@@ -307,9 +309,9 @@
if (e.code === 1008 && reason === "Unauthorized") {
if (!closedManually && !authRecoveryInFlight) {
authRecoveryInFlight = true;
const recoveredToken = await getWebSocketToken(true);
const refreshed = await tryRefreshSession();
authRecoveryInFlight = false;
if (recoveredToken) {
if (refreshed) {
reconnecting = true;
updateTab(tabId, {
connected: false,