feat(oauth): persist session token for automatic re-auth on restart

- Save user_auth_token after successful OAuth login
- On bootstrap, restore session via saved token if no email/password creds
- If token is expired, clear it so user sees login screen normally
- Clear OAuth token on v2_clear_saved_credentials (logout)
- Add login_with_token() to api client (reuses OAuth /user/login step)

Also rename t->translateStore in NowPlayingBar.svelte (ADR-001 fix from Codex)
and improve clean-dev-runtime.sh to kill all stale processes and ports
This commit is contained in:
vicrodh committed 2026-02-23 15:30:43 -06:00
1 parent eb609faf0b
commit 835fd19934
5 files changed
+271 -32

No files matched your search

+19 -3
View File
@@ -4,9 +4,25 @@ set -euo pipefail
# Kill stale dev processes that commonly leave Vite/Svelte CSS pipeline in a bad state.
pkill -f '[n]ode .*vite dev' || true
pkill -f '[n]ode .*tauri dev' || true
pkill -f '[c]argo run' || true
pkill -f '[t]arget/debug/qbz' || true
pkill -f '[t]arget/debug/qbz-nix' || true
# Clean generated frontend caches.
rm -rf .svelte-kit .vite node_modules/.vite
# Force-free dev ports in case a process survives pattern-based kill.
for port in 1420 1421; do
pids="$(lsof -ti :"${port}" 2>/dev/null || true)"
if [[ -n "${pids}" ]]; then
kill ${pids} 2>/dev/null || true
sleep 0.2
# Escalate only if still alive after TERM.
still_alive="$(lsof -ti :"${port}" 2>/dev/null || true)"
if [[ -n "${still_alive}" ]]; then
kill -9 ${still_alive} 2>/dev/null || true
fi
fi
done
echo "[qbz] Dev runtime cleaned (.svelte-kit/.vite + stale vite/tauri/qbz processes)."
# Clean generated frontend caches.
rm -rf .svelte-kit .vite node_modules/.vite build dist .cache
echo "[qbz] Dev runtime cleaned (processes + ports 1420/1421 + cache directories)."
+57
View File
@@ -280,6 +280,63 @@ impl QobuzClient {
}
}
/// Re-authenticate using a previously obtained `user_auth_token`.
///
/// Used at bootstrap when OAuth credentials are stored but email/password are not.
/// Calls `POST /user/login` with `X-User-Auth-Token` header — the same as step 2
/// of the OAuth code exchange flow. Returns an error if the token has expired.
pub async fn login_with_token(&self, token: &str) -> Result<UserSession> {
use reqwest::header::{HeaderMap, HeaderValue};
let tokens = self.tokens.read().await;
let app_id = tokens
.as_ref()
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?
.app_id
.clone();
drop(tokens);
let user_login_url = endpoints::build_url(paths::USER_LOGIN);
let mut headers = HeaderMap::new();
headers.insert(
"X-App-Id",
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
);
headers.insert(
"X-User-Auth-Token",
HeaderValue::from_str(token).map_err(|_| {
ApiError::AuthenticationError("Invalid token format".into())
})?,
);
log::info!("[OAuth] Restoring session from saved token");
let resp = self
.http
.post(&user_login_url)
.headers(headers)
.header("Content-Type", "text/plain;charset=UTF-8")
.body("extra=partner")
.send()
.await?;
match resp.status() {
StatusCode::OK => {
let json: Value = resp.json().await?;
let session = parse_login_response(&json)?;
*self.session.write().await = Some(session.clone());
log::info!("[OAuth] Session restored for user {}", session.user_id);
Ok(session)
}
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
"OAuth token expired or invalid".to_string(),
)),
status => Err(ApiError::ApiResponse(format!(
"Token re-auth failed: {}",
status
))),
}
}
/// Get current user info (display name, subscription, and expiry if available)
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
self.session.read().await.as_ref().map(|s| {
+92 -1
View File
@@ -598,6 +598,90 @@ pub async fn runtime_bootstrap(
// Not a fatal error - user can login manually
}
}
} else if let Ok(Some(oauth_token)) = crate::credentials::load_oauth_token() {
// No email/password credentials but there IS a saved OAuth token.
// Try to restore the session by calling /user/login with the stored token.
log::info!("[Runtime] No email/password credentials, trying saved OAuth token...");
let client = app_state.client.read().await;
match client.login_with_token(&oauth_token).await {
Ok(session) => {
log::info!(
"[Runtime] OAuth token re-auth successful for user {}",
session.user_id
);
manager.set_legacy_auth(true, Some(session.user_id)).await;
let _ = app.emit(
"runtime:event",
RuntimeEvent::AuthChanged {
logged_in: true,
user_id: Some(session.user_id),
},
);
// Wait for CoreBridge, then inject session
let cb_start = std::time::Instant::now();
let cb_timeout = std::time::Duration::from_secs(10);
loop {
if core_bridge.try_get().await.is_some() {
break;
}
if cb_start.elapsed() > cb_timeout {
log::error!("[Runtime] CoreBridge not available after 10s (OAuth restore)");
manager.set_bootstrap_in_progress(false).await;
return Err(RuntimeError::V2NotInitialized);
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
if let Some(bridge) = core_bridge.try_get().await {
let core_session: qbz_models::UserSession = match serde_json::to_value(&session)
.and_then(serde_json::from_value)
{
Ok(s) => s,
Err(e) => {
log::error!("[Runtime] OAuth session conversion failed: {}", e);
manager.set_bootstrap_in_progress(false).await;
return Err(RuntimeError::Internal(e.to_string()));
}
};
match bridge.login_with_session(core_session).await {
Ok(_) => {
log::info!("[Runtime] CoreBridge session restored via OAuth token");
manager.set_corebridge_auth(true).await;
}
Err(e) => {
log::error!("[Runtime] CoreBridge OAuth restore failed: {}", e);
manager.set_bootstrap_in_progress(false).await;
return Err(RuntimeError::V2AuthFailed(e));
}
}
}
if let Err(e) =
crate::session_lifecycle::activate_session(&app, session.user_id).await
{
log::error!("[Runtime] Session activation failed (OAuth restore): {}", e);
manager.set_legacy_auth(false, None).await;
manager.set_corebridge_auth(false).await;
let reason = DegradedReason::SessionActivationFailed(e.clone());
manager.set_degraded(reason.clone()).await;
manager.set_bootstrap_in_progress(false).await;
let _ = app.emit(
"runtime:event",
RuntimeEvent::RuntimeDegraded {
reason: reason.clone(),
},
);
return Err(RuntimeError::RuntimeDegraded(reason));
}
}
Err(e) => {
// Token expired — clear it and let user re-login via OAuth
log::warn!("[Runtime] OAuth token expired, clearing: {}", e);
let _ = crate::credentials::clear_oauth_token();
}
}
} else {
log::info!("[Runtime] No saved credentials, staying in InitializedNoAuth");
}
@@ -1254,6 +1338,12 @@ pub async fn v2_start_oauth_login(
});
}
// Persist the OAuth token so bootstrap can restore the session on next launch.
// Non-fatal: if saving fails, the user just has to re-login via OAuth.
if let Err(e) = crate::credentials::save_oauth_token(&session.user_auth_token) {
log::warn!("[V2] Failed to persist OAuth token: {}", e);
}
let _ = app.emit(
"runtime:event",
RuntimeEvent::RuntimeReady {
@@ -2663,7 +2753,8 @@ pub fn v2_save_credentials(email: String, password: String) -> Result<(), String
#[tauri::command]
pub fn v2_clear_saved_credentials() -> Result<(), String> {
crate::credentials::clear_qobuz_credentials()
crate::credentials::clear_qobuz_credentials()?;
crate::credentials::clear_oauth_token()
}
#[tauri::command]
+75
View File
@@ -21,6 +21,7 @@ const SERVICE_NAME: &str = "qbz";
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
const FALLBACK_FILE_NAME: &str = ".qbz-auth";
const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy";
const OAUTH_TOKEN_FILE_NAME: &str = ".qbz-oauth-token";
// Salt for key derivation (app-specific, not secret but adds uniqueness)
const KEY_DERIVATION_SALT: &[u8] = b"QbzNixCredentialEncryption2024";
@@ -417,6 +418,80 @@ pub fn clear_qobuz_credentials() -> Result<(), String> {
Ok(())
}
// ─── OAuth token persistence ──────────────────────────────────────────────────
//
// OAuth login produces a `user_auth_token` instead of email+password.
// We persist it encrypted the same way as regular credentials so the user
// doesn't have to re-authenticate via browser on every app start.
// The token is re-used at bootstrap via `POST /user/login` with the
// `X-User-Auth-Token` header. If it has expired Qobuz returns a 4xx and
// we clear the stored token so the user sees the login screen normally.
fn get_oauth_token_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("qbz").join(OAUTH_TOKEN_FILE_NAME))
}
/// Persist the OAuth `user_auth_token` to an AES-256-GCM encrypted file.
pub fn save_oauth_token(token: &str) -> Result<(), String> {
let path = get_oauth_token_path().ok_or("Could not determine config directory")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
// Re-use the same encryption machinery: wrap the token as the "email" field
// of a throwaway QobuzCredentials so we don't duplicate the crypto code.
let placeholder = QobuzCredentials {
email: token.to_string(),
password: String::new(),
};
let encrypted = encrypt_credentials(&placeholder)?;
fs::write(&path, encrypted)
.map_err(|e| format!("Failed to write OAuth token file: {}", e))?;
log::info!("[Credentials] OAuth token saved");
Ok(())
}
/// Load a previously saved OAuth `user_auth_token`, or `None` if absent.
pub fn load_oauth_token() -> Result<Option<String>, String> {
let path = match get_oauth_token_path() {
Some(p) => p,
None => return Ok(None),
};
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read OAuth token file: {}", e))?;
match decrypt_credentials(&content) {
Ok(placeholder) => {
log::info!("[Credentials] OAuth token loaded");
Ok(Some(placeholder.email))
}
Err(e) => {
log::warn!("[Credentials] Failed to decrypt OAuth token: {}", e);
Ok(None)
}
}
}
/// Delete the stored OAuth token (called on logout or token expiry).
pub fn clear_oauth_token() -> Result<(), String> {
if let Some(path) = get_oauth_token_path() {
if path.exists() {
fs::remove_file(&path)
.map_err(|e| format!("Failed to remove OAuth token file: {}", e))?;
log::info!("[Credentials] OAuth token cleared");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+28 -28
View File
@@ -21,7 +21,7 @@
import QualityBadge from './QualityBadge.svelte';
import AudioOutputBadges from './AudioOutputBadges.svelte';
import StackIcon from './StackIcon.svelte';
import { t } from '$lib/i18n';
import { t as translateStore } from '$lib/i18n';
import {
subscribe as subscribeOffline,
isOffline as checkIsOffline,
@@ -145,13 +145,13 @@
function getOfflineReasonText(reason: OfflineReason | null): string {
switch (reason) {
case 'no_network':
return $t('offline.noNetwork');
return $translateStore('offline.noNetwork');
case 'not_logged_in':
return $t('offline.notLoggedIn');
return $translateStore('offline.notLoggedIn');
case 'manual_override':
return $t('offline.manualMode');
return $translateStore('offline.manualMode');
default:
return $t('offline.indicator');
return $translateStore('offline.indicator');
}
}
@@ -251,7 +251,7 @@
class:disabled={controlsDisabled}
disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onToggleShuffle}
title={$t('player.shuffle')}
title={$translateStore('player.shuffle')}
>
<Shuffle size={16} />
</button>
@@ -261,7 +261,7 @@
class:disabled={controlsDisabled}
disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onSkipBack}
title={$t('player.previous')}
title={$translateStore('player.previous')}
>
<SkipBack size={18} />
</button>
@@ -271,7 +271,7 @@
class:disabled={controlsDisabled}
disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onTogglePlay}
title={isPlaying ? $t('player.pause') : $t('player.play')}
title={isPlaying ? $translateStore('player.pause') : $translateStore('player.play')}
>
{#if isPlaying}
<Pause size={20} />
@@ -285,7 +285,7 @@
class:disabled={controlsDisabled}
disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onSkipForward}
title={$t('player.next')}
title={$translateStore('player.next')}
>
<SkipForward size={18} />
</button>
@@ -296,7 +296,7 @@
class:disabled={controlsDisabled}
disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onToggleRepeat}
title={repeatMode === 'off' ? $t('player.repeat') : repeatMode === 'all' ? $t('player.repeatAll') : $t('player.repeatOne')}
title={repeatMode === 'off' ? $translateStore('player.repeat') : repeatMode === 'all' ? $translateStore('player.repeatAll') : $translateStore('player.repeatOne')}
>
{#if repeatMode === 'one'}
<Repeat1 size={16} />
@@ -310,7 +310,7 @@
class:disabled={controlsDisabled}
disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onAddToPlaylist}
title={$t('actions.addToPlaylist')}
title={$translateStore('actions.addToPlaylist')}
>
<Plus size={16} />
</button>
@@ -321,7 +321,7 @@
class:disabled={controlsDisabled}
disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onToggleFavorite}
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')}
title={isFavorite ? $translateStore('actions.removeFromFavorites') : $translateStore('actions.addToFavorites')}
>
<Heart size={16} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
@@ -352,11 +352,11 @@
</button>
<div class="song-info">
<button class="song-title" title={$t('actions.trackInfo')} onclick={onTrackClick}>{trackTitle}</button>
<button class="song-title" title={$translateStore('actions.trackInfo')} onclick={onTrackClick}>{trackTitle}</button>
<div class="song-meta">
<StackIcon size={12} class="stack-icon" onClick={onContextClick} />
{#if artist}
<button class="meta-link" onclick={onArtistClick} title={$t('actions.goToArtist')}>
<button class="meta-link" onclick={onArtistClick} title={$translateStore('actions.goToArtist')}>
{artist}
</button>
{/if}
@@ -364,7 +364,7 @@
<span class="meta-separator">·</span>
{/if}
{#if album}
<button class="meta-link" onclick={onAlbumClick} title={$t('actions.goToAlbum')}>
<button class="meta-link" onclick={onAlbumClick} title={$translateStore('actions.goToAlbum')}>
{album}
</button>
{/if}
@@ -380,7 +380,7 @@
</div>
{:else}
<div class="empty-state">
<span>{$t('player.noTrackPlaying')}</span>
<span>{$translateStore('player.noTrackPlaying')}</span>
</div>
{/if}
</div>
@@ -402,7 +402,7 @@
class="control-btn"
class:cast-active={isCastConnected}
onclick={onCast}
title={isCastConnected ? $t('player.castingManage') : $t('player.castToDevice')}
title={isCastConnected ? $translateStore('player.castingManage') : $translateStore('player.castToDevice')}
>
<Cast size={16} />
</button>
@@ -413,19 +413,19 @@
class:disabled={isOffline}
onclick={isOffline ? undefined : onToggleLyrics}
disabled={isOffline}
title={isOffline ? $t('offline.featureDisabled') : $t('player.lyrics')}
aria-label={isOffline ? $t('offline.featureDisabled') : $t('player.lyrics')}
title={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
aria-label={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
>
<Mic2 size={16} aria-hidden="true" />
</button>
{#if onOpenMiniPlayer}
<button class="control-btn" onclick={onOpenMiniPlayer} title={$t('player.miniPlayer')}>
<button class="control-btn" onclick={onOpenMiniPlayer} title={$translateStore('player.miniPlayer')}>
<PictureInPicture2 size={16} />
</button>
{/if}
<button class="control-btn" onclick={onOpenFullScreen} title={$t('player.fullScreen')}>
<button class="control-btn" onclick={onOpenFullScreen} title={$translateStore('player.fullScreen')}>
<Maximize2 size={16} />
</button>
@@ -435,10 +435,10 @@
class:norm-enabled={normalizationEnabled && (normalizationGain === null || normalizationGain === 1.0)}
onclick={onToggleNormalization}
title={!normalizationEnabled
? $t('player.normalizationOff')
? $translateStore('player.normalizationOff')
: normalizationGain !== null && normalizationGain !== 1.0
? $t('player.normalizationApplied')
: $t('player.normalizationOn')}
? $translateStore('player.normalizationApplied')
: $translateStore('player.normalizationOn')}
>
<span
class="norm-icon"
@@ -453,7 +453,7 @@
<button
class="control-btn volume-btn"
onclick={() => toggleMute()}
title={volume === 0 ? $t('player.unmute') : $t('player.mute')}
title={volume === 0 ? $translateStore('player.unmute') : $translateStore('player.mute')}
>
{#if volume === 0}
<VolumeX size={16} />
@@ -483,7 +483,7 @@
<button
class="control-btn volume-step-btn"
onclick={() => onVolumeChange?.(Math.max(0, volume - 5))}
title={$t('player.volumeDown')}
title={$translateStore('player.volumeDown')}
>
<Minus size={14} />
</button>
@@ -491,7 +491,7 @@
<button
class="control-btn volume-step-btn"
onclick={() => onVolumeChange?.(Math.min(100, volume + 5))}
title={$t('player.volumeUp')}
title={$translateStore('player.volumeUp')}
>
<Plus size={14} />
</button>
@@ -502,7 +502,7 @@
class="control-btn queue-btn"
class:active={queueOpen}
onclick={onOpenQueue}
title={$t('player.queue')}
title={$translateStore('player.queue')}
>
<svg width="18" height="18" viewBox="0 0 256 256" class="queue-icon" class:open={queueOpen}>
<path class="queue-play" d="M240,160l-64,40V120Z"/>