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. # 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 .*vite dev' || true
pkill -f '[n]ode .*tauri 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 pkill -f '[t]arget/debug/qbz-nix' || true
# Clean generated frontend caches. # Force-free dev ports in case a process survives pattern-based kill.
rm -rf .svelte-kit .vite node_modules/.vite 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) /// Get current user info (display name, subscription, and expiry if available)
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> { pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
self.session.read().await.as_ref().map(|s| { 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 // 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 { } else {
log::info!("[Runtime] No saved credentials, staying in InitializedNoAuth"); 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( let _ = app.emit(
"runtime:event", "runtime:event",
RuntimeEvent::RuntimeReady { RuntimeEvent::RuntimeReady {
@@ -2663,7 +2753,8 @@ pub fn v2_save_credentials(email: String, password: String) -> Result<(), String
#[tauri::command] #[tauri::command]
pub fn v2_clear_saved_credentials() -> Result<(), String> { 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] #[tauri::command]
+75
View File
@@ -21,6 +21,7 @@ const SERVICE_NAME: &str = "qbz";
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials"; const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
const FALLBACK_FILE_NAME: &str = ".qbz-auth"; const FALLBACK_FILE_NAME: &str = ".qbz-auth";
const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy"; 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) // Salt for key derivation (app-specific, not secret but adds uniqueness)
const KEY_DERIVATION_SALT: &[u8] = b"QbzNixCredentialEncryption2024"; const KEY_DERIVATION_SALT: &[u8] = b"QbzNixCredentialEncryption2024";
@@ -417,6 +418,80 @@ pub fn clear_qobuz_credentials() -> Result<(), String> {
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+28 -28
View File
@@ -21,7 +21,7 @@
import QualityBadge from './QualityBadge.svelte'; import QualityBadge from './QualityBadge.svelte';
import AudioOutputBadges from './AudioOutputBadges.svelte'; import AudioOutputBadges from './AudioOutputBadges.svelte';
import StackIcon from './StackIcon.svelte'; import StackIcon from './StackIcon.svelte';
import { t } from '$lib/i18n'; import { t as translateStore } from '$lib/i18n';
import { import {
subscribe as subscribeOffline, subscribe as subscribeOffline,
isOffline as checkIsOffline, isOffline as checkIsOffline,
@@ -145,13 +145,13 @@
function getOfflineReasonText(reason: OfflineReason | null): string { function getOfflineReasonText(reason: OfflineReason | null): string {
switch (reason) { switch (reason) {
case 'no_network': case 'no_network':
return $t('offline.noNetwork'); return $translateStore('offline.noNetwork');
case 'not_logged_in': case 'not_logged_in':
return $t('offline.notLoggedIn'); return $translateStore('offline.notLoggedIn');
case 'manual_override': case 'manual_override':
return $t('offline.manualMode'); return $translateStore('offline.manualMode');
default: default:
return $t('offline.indicator'); return $translateStore('offline.indicator');
} }
} }
@@ -251,7 +251,7 @@
class:disabled={controlsDisabled} class:disabled={controlsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onToggleShuffle} onclick={controlsDisabled ? undefined : onToggleShuffle}
title={$t('player.shuffle')} title={$translateStore('player.shuffle')}
> >
<Shuffle size={16} /> <Shuffle size={16} />
</button> </button>
@@ -261,7 +261,7 @@
class:disabled={controlsDisabled} class:disabled={controlsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onSkipBack} onclick={controlsDisabled ? undefined : onSkipBack}
title={$t('player.previous')} title={$translateStore('player.previous')}
> >
<SkipBack size={18} /> <SkipBack size={18} />
</button> </button>
@@ -271,7 +271,7 @@
class:disabled={controlsDisabled} class:disabled={controlsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onTogglePlay} onclick={controlsDisabled ? undefined : onTogglePlay}
title={isPlaying ? $t('player.pause') : $t('player.play')} title={isPlaying ? $translateStore('player.pause') : $translateStore('player.play')}
> >
{#if isPlaying} {#if isPlaying}
<Pause size={20} /> <Pause size={20} />
@@ -285,7 +285,7 @@
class:disabled={controlsDisabled} class:disabled={controlsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onSkipForward} onclick={controlsDisabled ? undefined : onSkipForward}
title={$t('player.next')} title={$translateStore('player.next')}
> >
<SkipForward size={18} /> <SkipForward size={18} />
</button> </button>
@@ -296,7 +296,7 @@
class:disabled={controlsDisabled} class:disabled={controlsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onToggleRepeat} 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'} {#if repeatMode === 'one'}
<Repeat1 size={16} /> <Repeat1 size={16} />
@@ -310,7 +310,7 @@
class:disabled={controlsDisabled} class:disabled={controlsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onAddToPlaylist} onclick={controlsDisabled ? undefined : onAddToPlaylist}
title={$t('actions.addToPlaylist')} title={$translateStore('actions.addToPlaylist')}
> >
<Plus size={16} /> <Plus size={16} />
</button> </button>
@@ -321,7 +321,7 @@
class:disabled={controlsDisabled} class:disabled={controlsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled}
onclick={controlsDisabled ? undefined : onToggleFavorite} 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'} /> <Heart size={16} fill={isFavorite ? 'currentColor' : 'none'} />
</button> </button>
@@ -352,11 +352,11 @@
</button> </button>
<div class="song-info"> <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"> <div class="song-meta">
<StackIcon size={12} class="stack-icon" onClick={onContextClick} /> <StackIcon size={12} class="stack-icon" onClick={onContextClick} />
{#if artist} {#if artist}
<button class="meta-link" onclick={onArtistClick} title={$t('actions.goToArtist')}> <button class="meta-link" onclick={onArtistClick} title={$translateStore('actions.goToArtist')}>
{artist} {artist}
</button> </button>
{/if} {/if}
@@ -364,7 +364,7 @@
<span class="meta-separator">·</span> <span class="meta-separator">·</span>
{/if} {/if}
{#if album} {#if album}
<button class="meta-link" onclick={onAlbumClick} title={$t('actions.goToAlbum')}> <button class="meta-link" onclick={onAlbumClick} title={$translateStore('actions.goToAlbum')}>
{album} {album}
</button> </button>
{/if} {/if}
@@ -380,7 +380,7 @@
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<span>{$t('player.noTrackPlaying')}</span> <span>{$translateStore('player.noTrackPlaying')}</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -402,7 +402,7 @@
class="control-btn" class="control-btn"
class:cast-active={isCastConnected} class:cast-active={isCastConnected}
onclick={onCast} onclick={onCast}
title={isCastConnected ? $t('player.castingManage') : $t('player.castToDevice')} title={isCastConnected ? $translateStore('player.castingManage') : $translateStore('player.castToDevice')}
> >
<Cast size={16} /> <Cast size={16} />
</button> </button>
@@ -413,19 +413,19 @@
class:disabled={isOffline} class:disabled={isOffline}
onclick={isOffline ? undefined : onToggleLyrics} onclick={isOffline ? undefined : onToggleLyrics}
disabled={isOffline} disabled={isOffline}
title={isOffline ? $t('offline.featureDisabled') : $t('player.lyrics')} title={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
aria-label={isOffline ? $t('offline.featureDisabled') : $t('player.lyrics')} aria-label={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
> >
<Mic2 size={16} aria-hidden="true" /> <Mic2 size={16} aria-hidden="true" />
</button> </button>
{#if onOpenMiniPlayer} {#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} /> <PictureInPicture2 size={16} />
</button> </button>
{/if} {/if}
<button class="control-btn" onclick={onOpenFullScreen} title={$t('player.fullScreen')}> <button class="control-btn" onclick={onOpenFullScreen} title={$translateStore('player.fullScreen')}>
<Maximize2 size={16} /> <Maximize2 size={16} />
</button> </button>
@@ -435,10 +435,10 @@
class:norm-enabled={normalizationEnabled && (normalizationGain === null || normalizationGain === 1.0)} class:norm-enabled={normalizationEnabled && (normalizationGain === null || normalizationGain === 1.0)}
onclick={onToggleNormalization} onclick={onToggleNormalization}
title={!normalizationEnabled title={!normalizationEnabled
? $t('player.normalizationOff') ? $translateStore('player.normalizationOff')
: normalizationGain !== null && normalizationGain !== 1.0 : normalizationGain !== null && normalizationGain !== 1.0
? $t('player.normalizationApplied') ? $translateStore('player.normalizationApplied')
: $t('player.normalizationOn')} : $translateStore('player.normalizationOn')}
> >
<span <span
class="norm-icon" class="norm-icon"
@@ -453,7 +453,7 @@
<button <button
class="control-btn volume-btn" class="control-btn volume-btn"
onclick={() => toggleMute()} onclick={() => toggleMute()}
title={volume === 0 ? $t('player.unmute') : $t('player.mute')} title={volume === 0 ? $translateStore('player.unmute') : $translateStore('player.mute')}
> >
{#if volume === 0} {#if volume === 0}
<VolumeX size={16} /> <VolumeX size={16} />
@@ -483,7 +483,7 @@
<button <button
class="control-btn volume-step-btn" class="control-btn volume-step-btn"
onclick={() => onVolumeChange?.(Math.max(0, volume - 5))} onclick={() => onVolumeChange?.(Math.max(0, volume - 5))}
title={$t('player.volumeDown')} title={$translateStore('player.volumeDown')}
> >
<Minus size={14} /> <Minus size={14} />
</button> </button>
@@ -491,7 +491,7 @@
<button <button
class="control-btn volume-step-btn" class="control-btn volume-step-btn"
onclick={() => onVolumeChange?.(Math.min(100, volume + 5))} onclick={() => onVolumeChange?.(Math.min(100, volume + 5))}
title={$t('player.volumeUp')} title={$translateStore('player.volumeUp')}
> >
<Plus size={14} /> <Plus size={14} />
</button> </button>
@@ -502,7 +502,7 @@
class="control-btn queue-btn" class="control-btn queue-btn"
class:active={queueOpen} class:active={queueOpen}
onclick={onOpenQueue} 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}> <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"/> <path class="queue-play" d="M240,160l-64,40V120Z"/>