mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: Revise Authentication Methods & Add Edge-Case Handling for Auth
This commit is contained in:
+127
@@ -0,0 +1,127 @@
|
||||
import { store, updateSettings } from "../store/state.svelte";
|
||||
|
||||
// Only NONE and BASIC_AUTH are supported. SIMPLE_LOGIN and UI_LOGIN are
|
||||
// recognised as values the server may report, but this client will not
|
||||
// attempt to authenticate with them — it will show an unsupported-mode
|
||||
// warning instead.
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
|
||||
export const authSession = {
|
||||
// These stubs exist so callers that imported authSession don't break.
|
||||
// Basic-auth credentials are never stored client-side; they are sent
|
||||
// per-request via the Authorization header.
|
||||
clearTokens() {},
|
||||
hasSession(): boolean { return true; },
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
const headers = user && pass ? basicHeader(user, pass) : {};
|
||||
return fetch(url, buildRequestInit({ ...init, signal }, headers));
|
||||
}
|
||||
|
||||
// SIMPLE_LOGIN, UI_LOGIN, and any future unknown modes: send the request
|
||||
// unauthenticated. The probe/login gate in App.svelte will have already
|
||||
// shown an unsupported-mode warning so the user knows requests may fail.
|
||||
return fetch(url, { ...init, signal });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
// Persist credentials through the store so fetchAuthenticated picks them up.
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// Basic auth has no server-side session to invalidate.
|
||||
updateSettings({ serverAuthPass: "" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
try {
|
||||
let headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
// If we have credentials, try them — a 200 means we're good.
|
||||
// If we don't have credentials yet, fall through to the unauthenticated
|
||||
// probe so we still get the WWW-Authenticate header back.
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (res.ok) return "ok";
|
||||
|
||||
if (res.status === 401) {
|
||||
// Sniff the WWW-Authenticate header to auto-detect the server's scheme.
|
||||
const wwwAuth = (res.headers.get("WWW-Authenticate") ?? "").toLowerCase();
|
||||
|
||||
if (/basic/i.test(wwwAuth)) {
|
||||
// Server wants Basic Auth — update the stored mode so the login gate
|
||||
// shows the right UI and fetchAuthenticated uses the right scheme.
|
||||
if (mode !== "BASIC_AUTH") {
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||
}
|
||||
return "auth_required";
|
||||
}
|
||||
|
||||
// Any other 401 (Bearer, Digest, cookie-based, etc.) is unsupported.
|
||||
// Try to figure out what it is for a better warning label.
|
||||
if (/bearer/i.test(wwwAuth)) {
|
||||
// Likely SIMPLE_LOGIN or UI_LOGIN — store it so the warning names it.
|
||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
} else if (mode === "NONE") {
|
||||
// Unknown scheme and we had no mode stored — store a sentinel so the
|
||||
// warning fires instead of an infinite auth_required loop.
|
||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||
}
|
||||
return "unsupported_mode";
|
||||
}
|
||||
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
}
|
||||
+12
-20
@@ -1,4 +1,5 @@
|
||||
import { store } from "../store/state.svelte";
|
||||
import { fetchAuthenticated } from "./auth";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
@@ -7,15 +8,6 @@ function getServerUrl(): string {
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||
}
|
||||
|
||||
function getAuthHeader(): Record<string, string> {
|
||||
const s = store.settings;
|
||||
if (!s.serverAuthEnabled) return {};
|
||||
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
|
||||
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
|
||||
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
@@ -41,22 +33,22 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal });
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
if (e?.authRequired) throw e;
|
||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||
if (i === retries - 1) throw e;
|
||||
@@ -67,14 +59,14 @@ async function fetchWithRetry(
|
||||
}
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(gqlUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
}, signal);
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
@@ -888,3 +888,20 @@ export const LOGOUT_TRACKER = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken {
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user