import { appState } from '$lib/state/app.svelte' import { authVerifiedState } from '$lib/state/auth.svelte' import { LOGIN_MUTATION, REFRESH_MUTATION } from '$lib/server-adapters/suwayomi/meta' const DEFAULT_URL = 'http://127.0.0.1:4567' const SKEW_MS = 60_000 * 2 interface AuthConfig { baseUrl: string mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' user?: string pass?: string } export interface UiAuthDebugStatus { mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' hasSession: boolean accessExpiresAt: number | null accessExpiresInMs: number | null shouldRefreshSoon: boolean refreshInFlight: boolean skewMs: number } let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' } let accessToken: string | null = null let refreshToken: string | null = null let accessExpiresAt: number | null = null let refreshInFlight = false let authSnoozed = false function parseExpiry(token: string): number | null { try { const payload = JSON.parse(atob(token.split('.')[1])) return typeof payload.exp === 'number' ? payload.exp * 1000 : null } catch { return null } } export const authSession = { clearTokens() { accessToken = null refreshToken = null accessExpiresAt = null }, } export function getUIAccessToken(): string | null { return accessToken } export function getUiAuthDebugStatus(): UiAuthDebugStatus { const now = Date.now() const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null return { mode: config.mode, hasSession: accessToken !== null, accessExpiresAt, accessExpiresInMs, shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS, refreshInFlight, skewMs: SKEW_MS, } } export function reportUnauthorized(): void { if (config.mode === 'NONE') return if (authSnoozed) return appState.authRequired = true authVerifiedState.value = false } export function reportAuthOk(): void { appState.authRequired = false } export function snoozeAuthPrompt(): void { authSnoozed = true appState.authRequired = false } export function configureAuth( baseUrl: string, mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', user?: string, pass?: string, ): void { config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass } accessToken = null refreshToken = null accessExpiresAt = null authSnoozed = false appState.authRequired = false } export function authHeaders(): Record { if (config.mode === 'BASIC_AUTH' && config.user && config.pass) { return { Authorization: 'Basic ' + btoa(`${config.user}:${config.pass}`) } } if (config.mode === 'UI_LOGIN' && accessToken) { return { Authorization: `Bearer ${accessToken}` } } return {} } async function gql(query: string, variables?: Record, bare = false): Promise { const headers: Record = { 'Content-Type': 'application/json' } if (!bare) Object.assign(headers, authHeaders()) const res = await fetch(`${config.baseUrl}/api/graphql`, { method: 'POST', headers, body: JSON.stringify({ query, variables }), }) if (res.status === 401 || res.status === 403) { reportUnauthorized() throw new Error(`HTTP ${res.status}`) } if (!res.ok) throw new Error(`HTTP ${res.status}`) const json = await res.json() if (json.errors?.length) { if (/unauthorized|unauthenticated/i.test(json.errors[0].message)) reportUnauthorized() throw new Error(json.errors[0].message) } return json.data as T } export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> { try { const res = await fetch(`${config.baseUrl}/api/graphql`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ query: '{ settings { authMode } }' }), }) if (res.status === 401 || res.status === 403) return 'auth_required' if (!res.ok) return 'unreachable' const json = await res.json() const isAuthError = json.errors?.some((e: { message: string }) => /unauthorized|unauthenticated/i.test(e.message) ) return isAuthError ? 'auth_required' : 'ok' } catch { return 'unreachable' } } export function loginBasic(user: string, pass: string): void { config.user = user config.pass = pass config.mode = 'BASIC_AUTH' authSnoozed = false reportAuthOk() } /** * Verify basic-auth credentials by making a real GQL request with them. * Throws if the server returns 401/403 or an auth error. */ export async function verifyBasicAuth(user: string, pass: string): Promise { const prev = { user: config.user, pass: config.pass, mode: config.mode } config.user = user config.pass = pass config.mode = 'BASIC_AUTH' try { await gql('{ settings { authMode } }') } catch { config.user = prev.user config.pass = prev.pass config.mode = prev.mode as typeof config.mode throw new Error('Invalid credentials') } authSnoozed = false reportAuthOk() } export async function loginUI(user: string, pass: string): Promise { const data = await gql<{ login: { accessToken: string; refreshToken: string } }>( LOGIN_MUTATION, { username: user, password: pass }, true ) accessToken = data.login.accessToken refreshToken = data.login.refreshToken accessExpiresAt = parseExpiry(accessToken) config.mode = 'UI_LOGIN' config.user = user authSnoozed = false reportAuthOk() } export async function refreshUiAccessToken(force = false): Promise { if (config.mode !== 'UI_LOGIN') return null if (!refreshToken) return null const now = Date.now() if (!force && accessExpiresAt !== null && accessExpiresAt - now > SKEW_MS) return accessToken if (refreshInFlight) return accessToken refreshInFlight = true try { const data = await gql<{ refreshToken: { accessToken: string } }>( REFRESH_MUTATION, { refreshToken } ) accessToken = data.refreshToken.accessToken accessExpiresAt = parseExpiry(accessToken) reportAuthOk() return accessToken } catch { reportUnauthorized() return null } finally { refreshInFlight = false } }