mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Implement jwt with refresh
This commit is contained in:
+20
-2
@@ -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 ?? "";
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
-4
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user