Files
jellytau/src/lib/stores/auth.ts
T
dtourolle da762da55d feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch
between them in a couple of taps. A profile can be locked behind a 4-8
digit PIN; one without a PIN is one tap away. Forgetting a PIN falls
through to the account's own Jellyfin password, so there is no reset flow
and no recovery secret to store.

Opt-in by construction: a single account with no PIN starts, plays and
downloads exactly as before, and never sees a picker.

Two decisions worth keeping:

- Switching is not logging out. auth_logout invalidates the token
  server-side, which is precisely what a switch must not do, or every
  switch back would cost a password. The switch runs as a plan
  (profiles/switch.rs) so the teardown *ordering* is unit-testable with
  no player and no server -- a straggler reporting after the active user
  flips would attribute one account's viewing to another, silently.

- The PIN gates switching, not the token at rest. Wrapping each token
  with its PIN would leave a locked profile unable to resume its own
  downloads or drain its own sync queue until somebody typed the code,
  which on a device that reboots nightly costs more than it defends
  against a four-digit secret. auth_initialize does refuse to restore a
  PIN-protected session, so the gate is on the session rather than on
  which screen is shown.

"Child account" is not modelled anywhere -- a child's profile is simply
one with no PIN. The frontend renders an opaque unlockMethod and never
compares a PIN, counts an attempt or infers a role.

Migration 024 adds user_pins, user_item_visibility, user_libraries and
download_grants, and backfills the existing user so an upgrade does not
blank its library. The visibility and grant tables are the schema half of
the cache-scoping and shared-download work; the read-path enforcement is
still to come (see docs/specs/multi-user-profiles.md).
2026-08-30 19:03:59 +02:00

699 lines
22 KiB
TypeScript

// Authentication state store with Rust backend
//
// All business logic (session management, verification, credential storage) is handled by Rust.
// This file is a thin Svelte store wrapper that calls Rust commands and listens to events.
//
// TRACES: UR-009, UR-012 | IR-009, IR-014
import { writable, derived, get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { RepositoryClient } from "$lib/api/repository-client";
import type { User, AuthResult } from "$lib/api/types";
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
import { connectivity } from "./connectivity";
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("Auth");
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
user: User | null;
serverUrl: string | null;
serverName: string | null;
error: string | null;
securityWarning: string | null;
/** Whether session needs re-authentication (e.g., token expired) */
needsReauth: boolean;
/** Whether session verification is in progress */
isVerifying: boolean;
/** Whether the session is known to be valid (verified with server) */
sessionVerified: boolean;
}
function createAuthStore() {
const initialState: AuthState = {
isAuthenticated: false,
isLoading: true,
user: null,
serverUrl: null,
serverName: null,
error: null,
securityWarning: null,
needsReauth: false,
isVerifying: false,
sessionVerified: false,
};
const { subscribe, set, update } = writable<AuthState>(initialState);
// RepositoryClient provides cache-first access with automatic background refresh via Rust
let repository: RepositoryClient | null = null;
// Store unlisten functions for cleanup
let unlistenSessionVerified: (() => void) | null = null;
let unlistenNeedsReauth: (() => void) | null = null;
let unlistenNetworkError: (() => void) | null = null;
function getRepository(): RepositoryClient {
if (!repository) {
throw new Error("Not connected to a server");
}
return repository;
}
/**
* The repository, waiting for session restore rather than failing the instant
* it is asked.
*
* `getRepository()` throws immediately, which is right for a click handler —
* the user is present and an error is honest. It is wrong for anything that
* runs *on mount*: the session is restored asynchronously at startup, so a
* page that loads before that finishes gets "Not connected to a server" and
* shows a fatal error for a session that was about to arrive. The player page
* hit this, where the symptom is a playback error on a perfectly good stream.
*
* Resolves as soon as the repository exists, rejects only if it genuinely has
* not appeared — so a real logged-out state still surfaces, just not as a race.
*
* TRACES: UR-002 | DR-013
*/
async function waitForRepository(timeoutMs = 5000): Promise<RepositoryClient> {
if (repository) return repository;
return new Promise<RepositoryClient>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
fn();
};
// Every store change is a chance the session landed. `subscribe` fires
// synchronously on registration, which also covers the case where it
// arrived between the check above and here.
const unsubscribe = subscribe(() => {
if (repository) finish(() => resolve(repository as RepositoryClient));
});
const timer = setTimeout(
() => finish(() => reject(new Error("Not connected to a server"))),
timeoutMs,
);
});
}
/**
* Initialize event listeners from Rust backend.
* These should be called once during app initialization.
*/
async function initializeEventListeners(): Promise<void> {
if (typeof window === "undefined") return;
try {
unlistenSessionVerified = await listen<{ user: User }>("auth:session-verified", (event) => {
log.debug("Session verified:", event.payload.user.name);
update((s) => ({
...s,
sessionVerified: true,
needsReauth: false,
isVerifying: false,
user: event.payload.user,
}));
});
} catch (e) {
log.error("Failed to listen to session-verified event:", e);
}
try {
unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => {
log.debug("Session needs re-authentication:", event.payload.reason);
update((s) => ({
...s,
sessionVerified: false,
needsReauth: true,
isVerifying: false,
error: event.payload.reason,
}));
});
} catch (e) {
log.error("Failed to listen to needs-reauth event:", e);
}
try {
unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => {
log.debug("Network error during verification:", event.payload.message);
// Network errors don't trigger re-auth - just log them
update((s) => ({ ...s, isVerifying: false }));
});
} catch (e) {
log.error("Failed to listen to network-error event:", e);
}
}
/**
* Cleanup event listeners.
* Should be called when the app is destroyed.
*/
function cleanupEventListeners(): void {
if (unlistenSessionVerified) {
unlistenSessionVerified();
unlistenSessionVerified = null;
}
if (unlistenNeedsReauth) {
unlistenNeedsReauth();
unlistenNeedsReauth = null;
}
if (unlistenNetworkError) {
unlistenNetworkError();
unlistenNetworkError = null;
}
}
/**
* Initialize auth state from Rust backend.
* This function does NOT require network access - session is restored immediately.
*/
async function initialize() {
// Initialize event listeners first
await initializeEventListeners();
update((s) => ({ ...s, isLoading: true, error: null }));
try {
// Check security status — fire-and-forget. It only sets a warning banner,
// so it must not sit in front of session restore (and thus first paint).
void (async () => {
try {
const securityStatus = await commands.storageGetSecurityStatus();
log.debug("Security status:", securityStatus);
if (!securityStatus.usingKeyring) {
update((s) => ({
...s,
securityWarning:
"Credentials are stored with reduced security (encrypted file instead of system keyring).",
}));
}
} catch (error) {
log.warn("Failed to get security status:", error);
}
})();
// Initialize auth manager and get session
log.debug("Initializing auth manager...");
const session = await commands.authInitialize();
log.debug("Session retrieval result:", session ? "Session found" : "No session found");
if (session) {
log.debug("Restoring session for user:", session.username, "on server:", session.serverUrl);
// Create RepositoryClient for cache-first access. This IS required before
// we mark authenticated — the first screen (library overview) reads
// through it — so keep it awaited.
repository = new RepositoryClient();
await repository.create(
session.serverUrl,
session.userId,
session.accessToken,
session.serverId,
);
// Configure the Rust player for playback reporting. This is NOT needed to
// render the first screen (it only matters once playback starts), so run
// it fire-and-forget instead of blocking first paint on two more IPC
// round-trips (getDeviceId + playerConfigureJellyfin).
void (async () => {
try {
const deviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
session.serverUrl,
session.accessToken,
session.userId,
deviceId,
);
log.debug("Rust player configured for automatic playback reporting");
} catch (error) {
log.error("Failed to configure Rust player:", error);
}
})();
// Set authenticated immediately (offline-first)
set({
isAuthenticated: true,
isLoading: false,
user: { id: session.userId, name: session.username, serverId: session.serverId } as User,
serverUrl: session.serverUrl,
serverName: session.serverName,
error: null,
securityWarning: initialState.securityWarning,
needsReauth: session.needsReauth,
isVerifying: false,
sessionVerified: session.verified,
});
// Start connectivity monitoring early to avoid appearing offline on startup
log.debug("Starting early connectivity monitoring...");
connectivity
.startMonitoring(session.serverUrl, {
onServerReconnected: () => {
// Retry session verification when server becomes reachable
retryVerification();
// Resume downloads queued while offline, then refresh the catalog.
// Lazy import to avoid an auth <-> offlineCatalog import cycle.
import("$lib/services/offlineCatalog")
.then((m) => m.onReconnected())
.catch((err) => log.warn("Catalog reconnect failed:", err));
},
})
.catch((error) => {
log.error("Failed to start connectivity monitoring:", error);
});
// Start background session verification — fire-and-forget. This is
// already asynchronous work (results arrive via the auth:* events wired
// above), so awaiting getDeviceId + authStartVerification here only
// delayed first paint by two IPC round-trips for no UI benefit.
void (async () => {
try {
const verifyDeviceId = await getDeviceId();
await commands.authStartVerification(verifyDeviceId);
log.debug("Background verification started");
} catch (error) {
log.error("Failed to start verification:", error);
}
})();
} else {
// No stored session
log.debug("No active session found");
set({
isAuthenticated: false,
isLoading: false,
user: null,
serverUrl: null,
serverName: null,
error: null,
securityWarning: initialState.securityWarning,
needsReauth: false,
isVerifying: false,
sessionVerified: false,
});
}
} catch (error) {
log.error("Failed to initialize:", error);
update((s) => ({
...s,
isLoading: false,
error: error instanceof Error ? error.message : String(error),
}));
}
}
/**
* Connect to a Jellyfin server and retrieve server info.
* Rust will normalize the URL (add https:// if missing, remove trailing slash).
*
* TRACES: UR-009 | IR-009
*/
async function connectToServer(serverUrl: string): Promise<ServerInfo> {
update((s) => ({ ...s, isLoading: true, error: null }));
try {
log.debug("Connecting to server:", serverUrl);
const serverInfo = await commands.authConnectToServer(serverUrl);
log.debug("Connected to server:", serverInfo.name, serverInfo.version);
log.debug("Normalized URL:", serverInfo.normalizedUrl);
update((s) => ({ ...s, isLoading: false }));
return serverInfo;
} catch (error) {
log.error("Failed to connect to server:", error);
update((s) => ({
...s,
isLoading: false,
error: error instanceof Error ? error.message : String(error),
}));
throw error;
}
}
/**
* Login with username and password.
*
* TRACES: UR-009, UR-012 | IR-009, IR-014
*/
async function login(username: string, password: string, serverUrl: string, serverName: string) {
update((s) => ({ ...s, isLoading: true, error: null }));
try {
const deviceId = await getDeviceId();
log.debug("Logging in as:", username);
const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
log.debug("Login successful:", authResult.user);
// Save to storage
await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
await commands.storageSaveUser(
authResult.user.id,
authResult.serverId,
authResult.user.name,
authResult.accessToken,
);
await commands.storageSetActiveUser(authResult.user.id, authResult.serverId);
// Set session in auth manager with server name
await commands.authSetSession({
userId: authResult.user.id,
username: authResult.user.name,
serverId: authResult.serverId,
serverUrl,
serverName,
accessToken: authResult.accessToken,
verified: true,
needsReauth: false,
});
// Create RepositoryClient
repository = new RepositoryClient();
await repository.create(
serverUrl,
authResult.user.id,
authResult.accessToken,
authResult.serverId,
);
// Configure Rust player
try {
const playerDeviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
serverUrl,
authResult.accessToken,
authResult.user.id,
playerDeviceId,
);
log.debug("Rust player configured for playback reporting");
} catch (error) {
log.error("Failed to configure Rust player:", error);
}
// Update state
set({
isAuthenticated: true,
isLoading: false,
user: authResult.user,
serverUrl,
serverName,
error: null,
securityWarning: initialState.securityWarning,
needsReauth: false,
isVerifying: false,
sessionVerified: true,
});
// Start background verification
try {
const verifyDeviceId = await getDeviceId();
await commands.authStartVerification(verifyDeviceId);
} catch (error) {
log.error("Failed to start verification:", error);
}
return authResult;
} catch (error) {
log.error("Login failed:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
throw error;
}
}
/**
* Re-authenticate with password (when session expired).
*/
async function reauthenticate(password: string) {
update((s) => ({ ...s, isLoading: true, error: null, needsReauth: false }));
try {
const deviceId = await getDeviceId();
log.debug("Re-authenticating...");
const authResult = await commands.authReauthenticate(password, deviceId);
log.debug("Re-authentication successful");
// Update storage
await commands.storageSaveUser(
authResult.user.id,
authResult.serverId,
authResult.user.name,
authResult.accessToken,
);
// Recreate repository with new credentials
if (repository) {
await repository.destroy();
const session = await commands.authGetSession();
if (session) {
await repository.create(
session.serverUrl,
authResult.user.id,
authResult.accessToken,
authResult.serverId,
);
}
}
// Reconfigure player
try {
const playerDeviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
repository ? await getCurrentSessionServerUrl() : "",
authResult.accessToken,
authResult.user.id,
playerDeviceId,
);
} catch (error) {
log.error("Failed to reconfigure player:", error);
}
// Update state
update((s) => ({
...s,
isLoading: false,
needsReauth: false,
sessionVerified: true,
user: authResult.user,
error: null,
}));
return authResult;
} catch (error) {
log.error("Re-authentication failed:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
throw error;
}
}
/**
* Logout and clear session.
*
* TRACES: UR-012 | IR-014
*/
async function logout() {
try {
const session = await commands.authGetSession();
if (session) {
const deviceId = await getDeviceId();
await commands.authLogout(session.serverUrl, session.accessToken, deviceId);
}
// Disable Jellyfin reporting in player
try {
await commands.playerDisableJellyfin();
} catch (error) {
log.error("Failed to disable player reporting:", error);
}
// Clear repository
if (repository) {
await repository.destroy();
}
repository = null;
set({
isAuthenticated: false,
isLoading: false,
user: null,
serverUrl: null,
serverName: null,
error: null,
securityWarning: null,
needsReauth: false,
isVerifying: false,
sessionVerified: false,
});
// Clear device ID cache on logout
clearDeviceIdCache();
} catch (error) {
log.error("Logout error (continuing anyway):", error);
set(initialState);
clearDeviceIdCache();
}
}
/**
* Clear error state.
*/
function clearError() {
update((s) => ({ ...s, error: null }));
}
/**
* Get current session from Rust backend.
*/
async function getCurrentSession() {
try {
return await commands.authGetSession();
} catch (error) {
log.error("Failed to get current session:", error);
return null;
}
}
/**
* Get current user ID.
*/
function getUserId(): string | null {
const state = get({ subscribe });
return state.user?.id || null;
}
/**
* Get server URL.
*/
function getServerUrl(): string | null {
const state = get({ subscribe });
return state.serverUrl;
}
/**
* Helper to get server URL from current session.
*/
async function getCurrentSessionServerUrl(): Promise<string> {
const session = await commands.authGetSession();
return session?.serverUrl || "";
}
/**
* Retry session verification (called when server becomes reachable again).
*/
async function retryVerification() {
try {
const deviceId = await getDeviceId();
log.debug("Retrying session verification after reconnection");
await commands.authStartVerification(deviceId);
} catch (error) {
log.error("Failed to retry verification:", error);
}
}
/**
* Rebuild this store's view of the world after the backend has switched
* profiles.
*
* The backend already flipped the active user, adopted the new session and
* destroyed the old repository handle — in that order, which is the part that
* matters. What is left is the half only the frontend owns: a `RepositoryClient`
* bound to the new session, and the player's reporting configuration.
*
* Deliberately *not* a login: no password is involved and no token is minted,
* because switching must leave both profiles able to come back with one tap.
*
* TRACES: UR-082 | DR-270
*/
async function adoptSwitchedSession() {
const session = await commands.authGetSession();
if (!session) throw new Error("No session after profile switch");
if (repository) {
try {
await repository.destroy();
} catch (error) {
log.error("Failed to destroy repository during switch:", error);
}
}
repository = new RepositoryClient();
await repository.create(
session.serverUrl,
session.userId,
session.accessToken,
session.serverId,
);
try {
const deviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
session.serverUrl,
session.accessToken,
session.userId,
deviceId,
);
} catch (error) {
log.error("Failed to reconfigure player after switch:", error);
}
set({
isAuthenticated: true,
isLoading: false,
user: { id: session.userId, name: session.username } as User,
serverUrl: session.serverUrl,
serverName: session.serverName,
error: null,
securityWarning: null,
needsReauth: false,
isVerifying: false,
sessionVerified: session.verified,
});
}
return {
subscribe,
initialize,
connectToServer,
login,
reauthenticate,
logout,
clearError,
getRepository,
waitForRepository,
getCurrentSession,
getUserId,
getServerUrl,
retryVerification,
adoptSwitchedSession,
cleanupEventListeners,
};
}
export const auth = createAuthStore();
export const isAuthenticated = derived(auth, ($auth) => $auth.isAuthenticated);
export const isLoading = derived(auth, ($auth) => $auth.isLoading);
export const currentUser = derived(auth, ($auth) => $auth.user);
export const needsReauth = derived(auth, ($auth) => $auth.needsReauth);
export const securityWarning = derived(auth, ($auth) => $auth.securityWarning);
export const authError = derived(auth, ($auth) => $auth.error);
export const isVerifying = derived(auth, ($auth) => $auth.isVerifying);
export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified);
export const serverName = derived(auth, ($auth) => $auth.serverName);
export const serverUrl = derived(auth, ($auth) => $auth.serverUrl);