Implement jwt with refresh

This commit is contained in:
Zerebos
2026-05-17 03:04:23 -04:00
parent bee8117aac
commit 61339ea006
4 changed files with 354 additions and 18 deletions
+20 -2
View File
@@ -1,5 +1,5 @@
import { store } from "@store/state.svelte"; 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 { boot } from "@store/boot.svelte";
import { getBlobUrl } from "@core/cache/imageCache"; import { getBlobUrl } from "@core/cache/imageCache";
@@ -104,6 +104,15 @@ export async function gql<T>(
variables?: Record<string, unknown>, variables?: Record<string, unknown>,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): 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 attempt = async (): Promise<T> => {
const res = await fetchWithRetry( const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`, `${getServerUrl()}/api/graphql`,
@@ -111,12 +120,21 @@ export async function gql<T>(
signal, signal,
); );
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); 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(); const json: GQLResponse<T> = await res.json();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) { if (json.errors?.length) {
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message)); const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
if (isAuthError && !boot.skipped) { if (isAuthError && !boot.skipped) {
const retried = await tryRefreshAndRetry();
if (retried) return retried;
boot.sessionExpired = true; boot.sessionExpired = true;
boot.loginRequired = true; boot.loginRequired = true;
boot.loginUser = store.settings.serverAuthUser ?? ""; boot.loginUser = store.settings.serverAuthUser ?? "";
+9 -4
View File
@@ -108,15 +108,20 @@ export const PUSH_KOSYNC_PROGRESS = `
`; `;
export const LOGIN_USER = ` export const LOGIN_USER = `
mutation Login($username: String!, $password: String!) { mutation Login($username: String!, $password: String!, $clientMutationId: String) {
login(input: { username: $username, password: $password }) { login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
accessToken accessToken
refreshToken
clientMutationId
} }
} }
`; `;
export const REFRESH_TOKEN = ` export const REFRESH_TOKEN = `
mutation RefreshToken { mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
refreshToken(input: {}) { accessToken } refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
accessToken
clientMutationId
}
} }
`; `;
+320 -8
View File
@@ -10,25 +10,156 @@ export class AuthRequiredError extends Error {
} }
const TOKEN_KEY = "moku_access_token"; const TOKEN_KEY = "moku_access_token";
const UI_SESSION_KEY = "moku_ui_auth_session";
const TOKEN_REFRESH_SKEW_MS = 30_000;
interface StoredAccessToken { interface StoredAccessToken {
base: string; base: string;
token: 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?: number | null;
jwtTokenExpiry?: number | null;
}
let _accessToken: string | null = null; let _accessToken: string | null = null;
let _accessTokenBase: 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 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 === "number" ? now + jwt.jwtTokenExpiry * 1000 : null);
const refreshExpiresAt =
typeof jwt?.jwtRefreshExpiry === "number" ? now + jwt.jwtRefreshExpiry * 1000 : null;
return { accessExpiresAt, refreshExpiresAt };
}
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
credentials: "omit",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`query GetJWTSettings {
settings {
jwtAudience
jwtRefreshExpiry
jwtTokenExpiry
}
}`,
),
signal: timeoutSignal(5000),
});
if (!res.ok) return null;
const json = await res.json();
if (json?.errors?.length) return null;
const settings = json?.data?.settings;
if (!settings || typeof settings !== "object") return null;
return {
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "number" ? settings.jwtRefreshExpiry : null,
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "number" ? 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 = { export const uiAuth = {
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: () => { getToken: () => {
const session = uiAuth.getSession();
if (!session) return null;
if (isExpired(session.accessExpiresAt, 0)) return null;
const base = getServerBase(); const base = getServerBase();
if (_accessToken && _accessTokenBase === base) return _accessToken; if (_accessToken && _accessTokenBase === base) return _accessToken;
const stored = readStoredToken(); const stored = readStoredToken();
if (!stored) return null; if (!stored) return null;
if (stored.base !== base) { if (stored.base !== base) {
sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
_accessToken = null; _accessToken = null;
_accessTokenBase = null; _accessTokenBase = null;
_uiSession = null;
return null; return null;
} }
_accessToken = stored.token; _accessToken = stored.token;
@@ -36,15 +167,49 @@ export const uiAuth = {
return _accessToken; return _accessToken;
}, },
setToken: (t: string) => { setToken: (t: string) => {
const existing = uiAuth.getSession();
if (existing?.refreshToken) {
uiAuth.setSession({
...existing,
accessToken: t,
...withExpiryFromSettings(t, _jwtSettings),
});
return;
}
const base = getServerBase(); const base = getServerBase();
_accessToken = t; _accessToken = t;
_accessTokenBase = base; _accessTokenBase = base;
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t })); 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,
refreshExpiresAt: existing.refreshExpiresAt,
});
},
clearToken: () => { clearToken: () => {
_accessToken = null; _accessToken = null;
_accessTokenBase = null; _accessTokenBase = null;
_uiSession = null;
sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(UI_SESSION_KEY);
}, },
}; };
@@ -52,7 +217,7 @@ export const authSession = {
clearTokens() { uiAuth.clearToken(); }, clearTokens() { uiAuth.clearToken(); },
hasSession(): boolean { hasSession(): boolean {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") return uiAuth.getToken() !== null; if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
return true; return true;
}, },
}; };
@@ -63,6 +228,9 @@ function getServerBase(): string {
} }
function readStoredToken(): StoredAccessToken | null { function readStoredToken(): StoredAccessToken | null {
const session = readStoredSession();
if (session) return { base: session.base, token: session.accessToken };
const raw = sessionStorage.getItem(TOKEN_KEY); const raw = sessionStorage.getItem(TOKEN_KEY);
if (raw?.trim()) { if (raw?.trim()) {
try { try {
@@ -79,6 +247,41 @@ function readStoredToken(): StoredAccessToken | null {
return null; 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 { function timeoutSignal(ms: number): AbortSignal {
const controller = new AbortController(); const controller = new AbortController();
setTimeout(() => controller.abort(), ms); setTimeout(() => controller.abort(), ms);
@@ -116,27 +319,125 @@ export async function fetchAuthenticated(
} }
if (mode === "UI_LOGIN") { if (mode === "UI_LOGIN") {
const token = uiAuth.getToken(); const token = await getUIAccessToken();
if (!token) { if (!token) {
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders }); if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
throw new AuthRequiredError(); throw new AuthRequiredError();
} }
return fetch(url, {
let res = await fetch(url, {
...init, signal, credentials: "omit", ...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(token) }, 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" }); 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)) {
uiAuth.clearToken();
return null;
}
if (_refreshPromise) return _refreshPromise;
_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) {
uiAuth.clearToken();
return null;
}
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)) {
uiAuth.clearToken();
return null;
}
throw new Error(msg ?? "Token refresh failed");
}
uiAuth.updateAccessToken(
{
accessToken: nextAccessToken,
clientMutationId: typeof refreshed?.clientMutationId === "string"
? refreshed.clientMutationId
: session.clientMutationId,
},
jwt,
);
return nextAccessToken;
})().finally(() => {
_refreshPromise = null;
});
return _refreshPromise;
}
export async function loginUI(user: string, pass: string): Promise<void> { export async function loginUI(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, { const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit", method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: gqlBody( body: gqlBody(
`mutation Login($username: String!, $password: String!) { `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 }, { username: user, password: pass },
), ),
@@ -144,9 +445,20 @@ export async function loginUI(user: string, pass: string): Promise<void> {
}); });
if (!res.ok) throw new Error(`Login request failed (${res.status})`); if (!res.ok) throw new Error(`Login request failed (${res.status})`);
const json = await res.json(); const json = await res.json();
const token: string | undefined = json?.data?.login?.accessToken; const payload = json?.data?.login;
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed"); const accessToken: string | undefined = payload?.accessToken;
uiAuth.setToken(token); const refreshToken: string | undefined = payload?.refreshToken;
if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
const jwt = await getJwtSettings(true).catch(() => null);
uiAuth.setLoginSession(
{
accessToken,
refreshToken,
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
},
jwt,
);
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" }); updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
} }
@@ -170,7 +482,7 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
const base = getServerBase(); const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings; const s = store.settings;
const token = uiAuth.getToken(); const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
if (mode === "UI_LOGIN" && !token) return "auth_required"; if (mode === "UI_LOGIN" && !token) return "auth_required";
+5 -4
View File
@@ -1,6 +1,6 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import { uiAuth } from "@core/auth"; import { getUIAccessToken } from "@core/auth";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
@@ -17,10 +17,10 @@ interface QueueEntry {
const queue: QueueEntry[] = []; const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> { async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") { if (mode === "UI_LOGIN") {
const token = uiAuth.getToken(); const token = await getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
@@ -32,7 +32,8 @@ function getAuthHeaders(): Record<string, string> {
} }
async function doFetch(url: string): Promise<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}`); if (!res.ok) throw new Error(`${res.status}`);
const blobUrl = URL.createObjectURL(await res.blob()); const blobUrl = URL.createObjectURL(await res.blob());
cache.set(url, blobUrl); cache.set(url, blobUrl);