mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Merge branch 'fix/auth'
This commit is contained in:
+20
-2
@@ -1,5 +1,5 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth";
|
||||
import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
|
||||
import { boot } from "@store/boot.svelte";
|
||||
import { getBlobUrl } from "@core/cache/imageCache";
|
||||
|
||||
@@ -104,6 +104,15 @@ export async function gql<T>(
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const tryRefreshAndRetry = async (): Promise<T | null> => {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode !== "UI_LOGIN" || boot.skipped) return null;
|
||||
const refreshed = await refreshUiAccessToken(true);
|
||||
if (!refreshed) return null;
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return attempt();
|
||||
};
|
||||
|
||||
const attempt = async (): Promise<T> => {
|
||||
const res = await fetchWithRetry(
|
||||
`${getServerUrl()}/api/graphql`,
|
||||
@@ -111,12 +120,21 @@ export async function gql<T>(
|
||||
signal,
|
||||
);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const retried = await tryRefreshAndRetry();
|
||||
if (retried) return retried;
|
||||
}
|
||||
throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
}
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) {
|
||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||
if (isAuthError && !boot.skipped) {
|
||||
const retried = await tryRefreshAndRetry();
|
||||
if (retried) return retried;
|
||||
|
||||
boot.sessionExpired = true;
|
||||
boot.loginRequired = true;
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
|
||||
@@ -108,15 +108,20 @@ export const PUSH_KOSYNC_PROGRESS = `
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
|
||||
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken(input: {}) { accessToken }
|
||||
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||
accessToken
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
+487
-15
@@ -10,19 +10,276 @@ export class AuthRequiredError extends Error {
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "moku_access_token";
|
||||
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
|
||||
const UI_SESSION_KEY = "moku_ui_auth_session";
|
||||
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||
const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
|
||||
|
||||
interface StoredAccessToken {
|
||||
base: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface StoredUiAuthSession {
|
||||
base: string;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
clientMutationId?: string;
|
||||
accessExpiresAt?: number | null;
|
||||
refreshExpiresAt?: number | null;
|
||||
}
|
||||
|
||||
interface JwtSettings {
|
||||
jwtAudience?: string | null;
|
||||
jwtRefreshExpiry?: string | null;
|
||||
jwtTokenExpiry?: string | null;
|
||||
}
|
||||
|
||||
export interface UiAuthDebugStatus {
|
||||
mode: AuthMode;
|
||||
serverBase: string;
|
||||
hasSession: boolean;
|
||||
hasRefreshToken: boolean;
|
||||
accessExpiresAt: number | null;
|
||||
refreshExpiresAt: number | null;
|
||||
accessExpiresInMs: number | null;
|
||||
refreshExpiresInMs: number | null;
|
||||
shouldRefreshSoon: boolean;
|
||||
refreshInFlight: boolean;
|
||||
skewMs: number;
|
||||
}
|
||||
|
||||
let _accessToken: string | null = null;
|
||||
let _accessTokenBase: string | null = null;
|
||||
let _uiSession: StoredUiAuthSession | null = null;
|
||||
let _refreshPromise: Promise<string | null> | null = null;
|
||||
let _jwtSettingsBase: string | null = null;
|
||||
let _jwtSettings: JwtSettings | null = null;
|
||||
let _jwtSettingsFetchedAt = 0;
|
||||
|
||||
function authDebug(event: string, fields?: Record<string, unknown>) {
|
||||
if (!AUTH_DEBUG) return;
|
||||
if (fields) {
|
||||
console.debug(`[auth] ${event}`, fields);
|
||||
return;
|
||||
}
|
||||
console.debug(`[auth] ${event}`);
|
||||
}
|
||||
|
||||
function parseIsoDuration(duration: string): number | null {
|
||||
try {
|
||||
const match = duration.match(
|
||||
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, years, months, days, hours, minutes, seconds] = match;
|
||||
let ms = 0;
|
||||
if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000;
|
||||
if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000;
|
||||
if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000;
|
||||
if (hours) ms += parseInt(hours) * 60 * 60 * 1000;
|
||||
if (minutes) ms += parseInt(minutes) * 60 * 1000;
|
||||
if (seconds) ms += parseFloat(seconds) * 1000;
|
||||
return ms;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
try {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return null;
|
||||
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
|
||||
const decoded = atob(padded);
|
||||
const json = JSON.parse(decoded) as { exp?: number };
|
||||
return typeof json.exp === "number" ? json.exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean {
|
||||
if (!expiresAt || !Number.isFinite(expiresAt)) return false;
|
||||
return Date.now() >= expiresAt - skewMs;
|
||||
}
|
||||
|
||||
function withExpiryFromSettings(
|
||||
accessToken: string,
|
||||
jwt: JwtSettings | null,
|
||||
): Pick<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
|
||||
const now = Date.now();
|
||||
const accessExpiresAt =
|
||||
decodeJwtExpiryMs(accessToken)
|
||||
?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null);
|
||||
const refreshExpiresAt =
|
||||
typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null;
|
||||
return { accessExpiresAt, refreshExpiresAt };
|
||||
}
|
||||
|
||||
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
|
||||
const res = await fetchAuthenticated(
|
||||
`${base}/api/graphql`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`query GetJWTSettings {
|
||||
settings {
|
||||
jwtAudience
|
||||
jwtRefreshExpiry
|
||||
jwtTokenExpiry
|
||||
}
|
||||
}`,
|
||||
),
|
||||
},
|
||||
timeoutSignal(5000),
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
authDebug("JWT settings fetch failed", { status: res.status });
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (json?.errors?.length) {
|
||||
authDebug("JWT settings query error", { errors: json.errors });
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = json?.data?.settings;
|
||||
if (!settings || typeof settings !== "object") {
|
||||
authDebug("JWT settings missing or invalid", { settings });
|
||||
return null;
|
||||
}
|
||||
|
||||
authDebug("JWT settings fetched", {
|
||||
hasAudience: !!settings.jwtAudience,
|
||||
tokenExpiry: settings.jwtTokenExpiry,
|
||||
refreshExpiry: settings.jwtRefreshExpiry,
|
||||
});
|
||||
|
||||
return {
|
||||
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
|
||||
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null,
|
||||
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
|
||||
const base = getServerBase();
|
||||
const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000;
|
||||
if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings;
|
||||
|
||||
const jwt = await fetchJwtSettings(base);
|
||||
_jwtSettingsBase = base;
|
||||
_jwtSettings = jwt;
|
||||
_jwtSettingsFetchedAt = Date.now();
|
||||
return jwt;
|
||||
}
|
||||
|
||||
export const uiAuth = {
|
||||
getToken: () => _accessToken,
|
||||
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
|
||||
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
|
||||
getSession: () => {
|
||||
const base = getServerBase();
|
||||
if (_uiSession && _uiSession.base === base) return _uiSession;
|
||||
|
||||
const stored = readStoredSession();
|
||||
if (!stored) return null;
|
||||
if (stored.base !== base) {
|
||||
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
_uiSession = null;
|
||||
_accessToken = null;
|
||||
_accessTokenBase = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
_uiSession = stored;
|
||||
_accessToken = stored.accessToken;
|
||||
_accessTokenBase = stored.base;
|
||||
return _uiSession;
|
||||
},
|
||||
setSession: (session: Omit<StoredUiAuthSession, "base">) => {
|
||||
const base = getServerBase();
|
||||
_uiSession = { ...session, base };
|
||||
_accessToken = session.accessToken;
|
||||
_accessTokenBase = base;
|
||||
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession));
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
},
|
||||
getToken: () => {
|
||||
const session = uiAuth.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
if (isExpired(session.accessExpiresAt, 0)) return null;
|
||||
|
||||
const base = getServerBase();
|
||||
if (_accessToken && _accessTokenBase === base) return _accessToken;
|
||||
const stored = readStoredToken();
|
||||
if (!stored) return null;
|
||||
if (stored.base !== base) {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||
_accessToken = null;
|
||||
_accessTokenBase = null;
|
||||
_uiSession = null;
|
||||
return null;
|
||||
}
|
||||
_accessToken = stored.token;
|
||||
_accessTokenBase = stored.base;
|
||||
return _accessToken;
|
||||
},
|
||||
setToken: (t: string) => {
|
||||
const existing = uiAuth.getSession();
|
||||
if (existing?.refreshToken) {
|
||||
uiAuth.setSession({
|
||||
...existing,
|
||||
accessToken: t,
|
||||
...withExpiryFromSettings(t, _jwtSettings),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const base = getServerBase();
|
||||
_accessToken = t;
|
||||
_accessTokenBase = base;
|
||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t }));
|
||||
},
|
||||
setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
||||
uiAuth.setSession({
|
||||
accessToken: payload.accessToken,
|
||||
refreshToken: payload.refreshToken,
|
||||
clientMutationId: payload.clientMutationId,
|
||||
...withExpiryFromSettings(payload.accessToken, jwt),
|
||||
});
|
||||
},
|
||||
updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
||||
const existing = uiAuth.getSession();
|
||||
if (!existing?.refreshToken) {
|
||||
uiAuth.setToken(payload.accessToken);
|
||||
return;
|
||||
}
|
||||
uiAuth.setSession({
|
||||
...existing,
|
||||
accessToken: payload.accessToken,
|
||||
clientMutationId: payload.clientMutationId ?? existing.clientMutationId,
|
||||
...withExpiryFromSettings(payload.accessToken, jwt),
|
||||
refreshToken: existing.refreshToken,
|
||||
});
|
||||
},
|
||||
clearToken: () => {
|
||||
_accessToken = null;
|
||||
_accessTokenBase = null;
|
||||
_uiSession = null;
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() { uiAuth.clearToken(); },
|
||||
hasSession(): boolean {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") return _accessToken !== null;
|
||||
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -32,6 +289,61 @@ function getServerBase(): string {
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function readStoredToken(): StoredAccessToken | null {
|
||||
const session = readStoredSession();
|
||||
if (session) return { base: session.base, token: session.accessToken };
|
||||
|
||||
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (raw?.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
|
||||
return { base: parsed.base, token: parsed.token };
|
||||
} catch {}
|
||||
|
||||
const migrated = { base: getServerBase(), token: raw.trim() };
|
||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readStoredSession(): StoredUiAuthSession | null {
|
||||
const raw = sessionStorage.getItem(UI_SESSION_KEY);
|
||||
if (raw?.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
|
||||
return {
|
||||
base: parsed.base,
|
||||
accessToken: parsed.accessToken,
|
||||
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
|
||||
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
|
||||
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
|
||||
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const legacy = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (!legacy?.trim()) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(legacy);
|
||||
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
|
||||
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
|
||||
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
|
||||
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function timeoutSignal(ms: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
@@ -69,27 +381,172 @@ export async function fetchAuthenticated(
|
||||
}
|
||||
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
const token = await getUIAccessToken();
|
||||
if (!token) {
|
||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
return fetch(url, {
|
||||
|
||||
let res = await fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||
});
|
||||
|
||||
if (res.status !== 401 || skipped) return res;
|
||||
|
||||
const refreshed = await refreshUiAccessToken(true);
|
||||
if (!refreshed) return res;
|
||||
|
||||
res = await fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||
}
|
||||
|
||||
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
|
||||
const session = uiAuth.getSession();
|
||||
if (!session) return null;
|
||||
if (forceRefresh || isExpired(session.accessExpiresAt)) {
|
||||
return refreshUiAccessToken(true);
|
||||
}
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||
const session = uiAuth.getSession();
|
||||
if (!session) return null;
|
||||
if (!session.refreshToken) {
|
||||
if (force && isExpired(session.accessExpiresAt, 0)) return null;
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken;
|
||||
if (isExpired(session.refreshExpiresAt)) {
|
||||
authDebug("refresh skipped: refresh token expired", {
|
||||
force,
|
||||
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
||||
});
|
||||
uiAuth.clearToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_refreshPromise) {
|
||||
authDebug("refresh joined existing request");
|
||||
return _refreshPromise;
|
||||
}
|
||||
|
||||
authDebug("refresh start", {
|
||||
force,
|
||||
accessExpiresAt: session.accessExpiresAt ?? null,
|
||||
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
||||
});
|
||||
|
||||
_refreshPromise = (async () => {
|
||||
const base = getServerBase();
|
||||
const jwt = await getJwtSettings().catch(() => null);
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "omit",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||
accessToken
|
||||
clientMutationId
|
||||
}
|
||||
}`,
|
||||
{ refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined },
|
||||
),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
authDebug("refresh rejected by server", { status: res.status });
|
||||
uiAuth.clearToken();
|
||||
return null;
|
||||
}
|
||||
authDebug("refresh failed with HTTP error", { status: res.status });
|
||||
throw new Error(`Token refresh failed (${res.status})`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const refreshed = json?.data?.refreshToken;
|
||||
const nextAccessToken: string | undefined = refreshed?.accessToken;
|
||||
if (!nextAccessToken) {
|
||||
const msg = json?.errors?.[0]?.message;
|
||||
if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) {
|
||||
authDebug("refresh rejected by GraphQL error", { message: msg });
|
||||
uiAuth.clearToken();
|
||||
return null;
|
||||
}
|
||||
authDebug("refresh returned no access token", { message: msg ?? null });
|
||||
throw new Error(msg ?? "Token refresh failed");
|
||||
}
|
||||
|
||||
uiAuth.updateAccessToken(
|
||||
{
|
||||
accessToken: nextAccessToken,
|
||||
clientMutationId: typeof refreshed?.clientMutationId === "string"
|
||||
? refreshed.clientMutationId
|
||||
: session.clientMutationId,
|
||||
},
|
||||
jwt,
|
||||
);
|
||||
authDebug("refresh success", {
|
||||
nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null,
|
||||
});
|
||||
return nextAccessToken;
|
||||
})()
|
||||
.catch((e: unknown) => {
|
||||
authDebug("refresh threw error", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
throw e;
|
||||
})
|
||||
.finally(() => {
|
||||
_refreshPromise = null;
|
||||
});
|
||||
|
||||
return _refreshPromise;
|
||||
}
|
||||
|
||||
export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus {
|
||||
const session = uiAuth.getSession();
|
||||
const accessExpiresAt = session?.accessExpiresAt ?? null;
|
||||
const refreshExpiresAt = session?.refreshExpiresAt ?? null;
|
||||
|
||||
return {
|
||||
mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode,
|
||||
serverBase: getServerBase(),
|
||||
hasSession: !!session,
|
||||
hasRefreshToken: !!session?.refreshToken,
|
||||
accessExpiresAt,
|
||||
refreshExpiresAt,
|
||||
accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null,
|
||||
refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null,
|
||||
shouldRefreshSoon: isExpired(accessExpiresAt),
|
||||
refreshInFlight: _refreshPromise !== null,
|
||||
skewMs: TOKEN_REFRESH_SKEW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) { accessToken }
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
clientMutationId
|
||||
}
|
||||
}`,
|
||||
{ username: user, password: pass },
|
||||
),
|
||||
@@ -97,10 +554,24 @@ export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
const token: string | undefined = json?.data?.login?.accessToken;
|
||||
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||
uiAuth.setToken(token);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
const payload = json?.data?.login;
|
||||
const accessToken: string | undefined = payload?.accessToken;
|
||||
const refreshToken: string | undefined = payload?.refreshToken;
|
||||
if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||
|
||||
authDebug("login success", { user });
|
||||
|
||||
const preliminarySession = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
|
||||
};
|
||||
|
||||
uiAuth.setLoginSession(preliminarySession, null);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
|
||||
|
||||
const jwt = await getJwtSettings(true).catch(() => null);
|
||||
uiAuth.setLoginSession(preliminarySession, jwt);
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
@@ -123,8 +594,9 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
|
||||
|
||||
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required";
|
||||
if (mode === "UI_LOGIN" && !token) return "auth_required";
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
@@ -132,8 +604,8 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
} else if (mode === "UI_LOGIN" && _accessToken) {
|
||||
Object.assign(headers, bearerHeader(_accessToken));
|
||||
} else if (mode === "UI_LOGIN" && token) {
|
||||
Object.assign(headers, bearerHeader(token));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
|
||||
Vendored
+5
-4
@@ -1,6 +1,6 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { uiAuth } from "@core/auth";
|
||||
import { getUIAccessToken } from "@core/auth";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
@@ -18,10 +18,10 @@ interface QueueEntry {
|
||||
|
||||
const queue: QueueEntry[] = [];
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
const token = await getUIAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
if (mode === "BASIC_AUTH") {
|
||||
@@ -33,7 +33,8 @@ function getAuthHeaders(): Record<string, string> {
|
||||
}
|
||||
|
||||
async function doFetch(url: string): Promise<string> {
|
||||
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await tauriFetch(url, { method: "GET", headers });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const blob = await res.blob();
|
||||
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import ThreeDCard from "@shared/manga/ThreeDCard.svelte";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import { cache } from "@core/cache/index";
|
||||
import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from "@core/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; }
|
||||
@@ -12,13 +13,69 @@
|
||||
let appVersion = $state("…");
|
||||
let helloAvailable = $state<boolean | null>(null);
|
||||
let helloBusy = $state(false);
|
||||
let authStatus = $state<UiAuthDebugStatus | null>(null);
|
||||
let authRefreshBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
|
||||
refreshPerfMetrics();
|
||||
refreshAuthStatus();
|
||||
invoke<boolean>("windows_hello_available").then(v => helloAvailable = v).catch(() => helloAvailable = false);
|
||||
|
||||
const timer = setInterval(() => refreshAuthStatus(), 1000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
function refreshAuthStatus() {
|
||||
authStatus = getUiAuthDebugStatus();
|
||||
}
|
||||
|
||||
function fmtCountdown(ms: number | null): string {
|
||||
if (ms === null) return "—";
|
||||
if (ms <= 0) return "expired";
|
||||
|
||||
const total = Math.floor(ms / 1000);
|
||||
const month = 30 * 24 * 60 * 60;
|
||||
const day = 24 * 60 * 60;
|
||||
const hour = 60 * 60;
|
||||
const minute = 60;
|
||||
|
||||
const months = Math.floor(total / month);
|
||||
const days = Math.floor((total % month) / day);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const remainingHours = Math.floor((total % day) / hour);
|
||||
const mins = Math.floor((total % hour) / minute);
|
||||
const secs = total % 60;
|
||||
|
||||
if (months > 0) return days > 0 ? `${months}mo ${days}d` : `${months}mo`;
|
||||
if (days > 0) return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
||||
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
|
||||
if (mins > 0) return `${mins}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
function fmtTime(ts: number | null): string {
|
||||
if (ts === null) return "—";
|
||||
return new Date(ts).toLocaleString([], { dateStyle: "medium", timeStyle: "medium" });
|
||||
}
|
||||
|
||||
async function forceTokenRefresh() {
|
||||
authRefreshBusy = true;
|
||||
try {
|
||||
const token = await refreshUiAccessToken(true);
|
||||
addToast({
|
||||
kind: token ? "success" : "info",
|
||||
title: "UI auth refresh",
|
||||
body: token ? "Refresh succeeded" : "No refreshed token available",
|
||||
});
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "UI auth refresh", body: String(e?.message ?? e) });
|
||||
} finally {
|
||||
authRefreshBusy = false;
|
||||
refreshAuthStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPerfMetrics() {
|
||||
let entries = 0, oldest: number | null = null, newest: number | null = null;
|
||||
const foundKeys: string[] = [];
|
||||
@@ -75,7 +132,7 @@
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
|
||||
<div class="s-dev-pill-group">
|
||||
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]}
|
||||
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label] (kind)}
|
||||
<button class="s-dev-pill {kind}" onclick={() => addToast({
|
||||
kind,
|
||||
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
|
||||
@@ -122,7 +179,7 @@
|
||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
|
||||
<span class="s-desc">3D tilt cards — hover to preview</span>
|
||||
<div style="display:flex;gap:var(--sp-3)">
|
||||
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card}
|
||||
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card (card.title)}
|
||||
<ThreeDCard>
|
||||
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
|
||||
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
|
||||
@@ -159,4 +216,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Auth (UI Login)</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-dev-grid">
|
||||
<span class="s-dev-key">Mode</span> <span class="s-dev-val">{authStatus?.mode ?? "—"}</span>
|
||||
<span class="s-dev-key">Session</span> <span class="s-dev-val">{authStatus?.hasSession ? "present" : "none"}</span>
|
||||
<span class="s-dev-key">Refresh token</span> <span class="s-dev-val">{authStatus?.hasRefreshToken ? "present" : "none"}</span>
|
||||
<span class="s-dev-key">Access expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.accessExpiresInMs ?? null)}</span>
|
||||
<span class="s-dev-key">Refresh expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.refreshExpiresInMs ?? null)}</span>
|
||||
<span class="s-dev-key">Refresh window</span> <span class="s-dev-val">{authStatus?.shouldRefreshSoon ? "open" : "not yet"}</span>
|
||||
<span class="s-dev-key">Refresh in-flight</span> <span class="s-dev-val">{authStatus?.refreshInFlight ? "yes" : "no"}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-desc">Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)}</span>
|
||||
<span class="s-desc">Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)}</span>
|
||||
<span class="s-desc">Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn" onclick={refreshAuthStatus}>Refresh</button>
|
||||
<button class="s-btn s-btn-accent" onclick={forceTokenRefresh} disabled={authRefreshBusy || authStatus?.mode !== "UI_LOGIN" || !authStatus?.hasRefreshToken}>
|
||||
{authRefreshBusy ? "Refreshing…" : "Force refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { authSession } from "@core/auth";
|
||||
import { authSession, loginUI } from "@core/auth";
|
||||
import { GET_SERVER_SECURITY } from "@api/queries/extensions";
|
||||
import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
|
||||
let flareFallback = $state(store.settings.flareSolverrAsResponseFallback ?? false);
|
||||
|
||||
function normalizeAuthMode(mode: string): "NONE" | "BASIC_AUTH" | "UI_LOGIN" {
|
||||
if (mode === "BASIC_AUTH" || mode === "UI_LOGIN" || mode === "NONE") return mode;
|
||||
return "NONE";
|
||||
}
|
||||
|
||||
function showSaved(key: string) {
|
||||
secSaved = key; secError = null;
|
||||
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
|
||||
@@ -53,9 +58,10 @@
|
||||
flareSolverrAsResponseFallback: boolean;
|
||||
}}>(GET_SERVER_SECURITY);
|
||||
const s = res.settings;
|
||||
authMode = store.settings.serverAuthMode ?? "NONE";
|
||||
authUsername = s.authUsername || store.settings.serverAuthUser || "";
|
||||
updateSettings({ serverAuthUser: authUsername });
|
||||
const serverMode = normalizeAuthMode(s.authMode);
|
||||
authMode = serverMode;
|
||||
authUsername = s.authUsername || "";
|
||||
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername });
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
||||
socksUsername = s.socksProxyUsername;
|
||||
@@ -82,23 +88,30 @@
|
||||
try {
|
||||
const newUser = authMode !== "NONE" ? authUsername.trim() : "";
|
||||
const newPass = authMode !== "NONE" ? authPassword.trim() : "";
|
||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||
|
||||
authSession.clearTokens();
|
||||
if (authMode === "UI_LOGIN") {
|
||||
authSession.clearTokens();
|
||||
await loginUI(newUser, newPass);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" });
|
||||
} else if (authMode === "BASIC_AUTH") {
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
} else {
|
||||
authSession.clearTokens();
|
||||
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
||||
}
|
||||
|
||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||
|
||||
authPassword = "";
|
||||
showSaved("auth");
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
||||
secError = e?.message ?? "Failed to save authentication settings";
|
||||
const msg = e?.message ?? "Failed to save authentication settings";
|
||||
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg);
|
||||
if (!authMismatch) {
|
||||
authSession.clearTokens();
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
||||
}
|
||||
secError = authMismatch
|
||||
? "Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration."
|
||||
: msg;
|
||||
} finally { secLoading = false; }
|
||||
}
|
||||
|
||||
@@ -223,7 +236,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
||||
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}>
|
||||
disabled={secLoading || ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim()))}>
|
||||
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user