Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
797 changes: 598 additions & 199 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"isomorphic-dompurify": "2.13.0",
"js-yaml": "^4.1.0",
"minimist": "^1.2.8",
"mongodb-memory-server": "^11.0.1",
"playwright": "^1.55.1",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.2.6",
Expand Down
48 changes: 46 additions & 2 deletions frontend/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,29 @@ export const handle: Handle = async ({ event, resolve }) => {
) {
// To get the same CSRF token after callback
refreshSessionCookie(event.cookies, auth.secretSessionId);
return await triggerOauthFlow(event);
const oauthResponse = await triggerOauthFlow(event);
if (oauthResponse.status === 502) {
const accept = event.request.headers.get("accept") || "";
const wantsHtml = accept.includes("text/html");

if (wantsHtml) {
try {
const body = await oauthResponse.clone().json();
if (typeof body?.code === "string") {
return new Response(null, {
status: 302,
headers: {
Location: `${base}/?error=${encodeURIComponent(body.code)}`,
"Cache-Control": "no-store",
},
});
}
} catch {
// fall through and return original structured 502 response
}
}
}
return oauthResponse;
Comment on lines +180 to +202
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 502-to-302 HTML redirect handling is duplicated in two branches in this file, which makes it easy for the behavior to drift over time (e.g., headers/message parsing). Consider extracting this into a small helper function (e.g., redirectOauthErrorIfHtml(event, oauthResponse)) and reusing it in both call sites.

Copilot uses AI. Check for mistakes.
}
} else {
// Redirect to OAuth flow unless on the authorized pages (home, shared conversation, login, healthcheck, model thumbnails)
Expand All @@ -193,7 +215,29 @@ export const handle: Handle = async ({ event, resolve }) => {
!event.url.pathname.startsWith(`${base}/api`)
) {
refreshSessionCookie(event.cookies, auth.secretSessionId);
return triggerOauthFlow(event);
const oauthResponse = await triggerOauthFlow(event);
if (oauthResponse.status === 502) {
const accept = event.request.headers.get("accept") || "";
const wantsHtml = accept.includes("text/html");

if (wantsHtml) {
try {
const body = await oauthResponse.clone().json();
if (typeof body?.code === "string") {
return new Response(null, {
status: 302,
headers: {
Location: `${base}/?error=${encodeURIComponent(body.code)}`,
"Cache-Control": "no-store",
},
});
}
} catch {
// fall through and return original structured 502 response
}
}
}
return oauthResponse;
}
}
}
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/lib/components/SignInButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script lang="ts">
import { onMount } from "svelte";
import { base } from "$app/paths";
import { page } from "$app/state";
import { error as errorStore, ERROR_MESSAGES } from "$lib/stores/errors";

interface Props {
/** Optional CSS classes to apply to the button element. */
classNames?: string;
/** Text label for the button. */
label?: string;
}

let { classNames = "", label = "Sign in" }: Props = $props();

const OAUTH_ERROR_MESSAGES: Record<string, string> = {
OAUTH_PROVIDER_UNAVAILABLE:
"Sign-in service is temporarily unavailable. Please try again later.",
OAUTH_PROVIDER_ERROR: "An error occurred during sign-in. Please try again.",
};

onMount(() => {
const errorCode = page.url.searchParams.get("error");
if (!errorCode) return;

errorStore.set(OAUTH_ERROR_MESSAGES[errorCode] ?? ERROR_MESSAGES.default);
});
Comment on lines +14 to +27
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new SignInButton component isn't referenced anywhere under frontend/src (no imports/usages found), so it may be dead code and won’t affect the login UX. Either wire it into the relevant page(s) or remove it; if it is intended to handle ?error= toasts, that logic is now duplicated with the root +layout.svelte handler and should be centralized in one place to avoid double-setting the error store.

Copilot uses AI. Check for mistakes.

function onSignIn() {
window.location.assign(`${base}/login`);
}
</script>

<button type="button" class={classNames} on:click={onSignIn}>
{label}
</button>
234 changes: 234 additions & 0 deletions frontend/src/lib/server/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* Unit tests for OAuth provider error handling in auth.ts
*
* Runs in the "server" Vitest workspace (environment: node).
* Uses vi.hoisted() so that mock variables are accessible inside vi.mock() factories.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";

// ---------------------------------------------------------------------------
// Hoist mock references so they can be used in vi.mock() factories
// ---------------------------------------------------------------------------
const { mockIssuerDiscover } = vi.hoisted(() => ({
mockIssuerDiscover: vi.fn(),
}));

Comment on lines +12 to +15
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These newly added tests use spaces for indentation, but the repo's Prettier config sets useTabs: true (frontend/.prettierrc). This will likely fail npm run lint (prettier --check .) unless reformatted. Please run Prettier (or adjust indentation to tabs) on this file.

Copilot uses AI. Check for mistakes.
// ---------------------------------------------------------------------------
// All vi.mock() calls – must appear before any named imports
// ---------------------------------------------------------------------------
vi.mock("$app/paths", () => ({ base: "" }));
vi.mock("$app/environment", () => ({ dev: false }));
vi.mock("$lib/migrations/lock", () => ({
acquireLock: vi.fn(),
releaseLock: vi.fn(),
isDBLocked: vi.fn(),
}));
vi.mock("$lib/utils/sha256", () => ({
sha256: vi.fn(async (v: string) => v + "-hashed"),
}));
vi.mock("bson-objectid", () => ({ default: class ObjectId { } }));
vi.mock("$lib/types/Semaphore", () => ({ Semaphores: { OAUTH_TOKEN_REFRESH: "oauth_refresh" } }));
vi.mock("$lib/server/database", () => ({ collections: {} }));
vi.mock("./adminToken", () => ({
adminTokenManager: { isAdmin: vi.fn(() => false) },
}));
vi.mock("$lib/server/logger", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
vi.mock("$lib/server/config", () => ({
config: {
OPENID_CLIENT_ID: "test-client-id",
OPENID_CLIENT_SECRET: "test-client-secret",
OPENID_PROVIDER_URL: "https://provider.example.com",
OPENID_SCOPES: "openid profile",
OPENID_NAME_CLAIM: "name",
OPENID_TOLERANCE: "",
OPENID_RESOURCE: "",
OPENID_CONFIG: "{}",
COOKIE_NAME: "hf-chat",
COOKIE_SAMESITE: "",
COOKIE_SECURE: "",
ALLOW_INSECURE_COOKIES: "true",
PUBLIC_ORIGIN: "http://localhost:5173",
COUPLE_SESSION_WITH_COOKIE_NAME: "",
TRUSTED_EMAIL_HEADER: "",
ALTERNATIVE_REDIRECT_URLS: [],
},
}));
vi.mock("openid-client", () => ({
Issuer: { discover: mockIssuerDiscover },
custom: { clock_tolerance: Symbol("clock_tolerance") },
generators: {
codeVerifier: vi.fn(() => "test-code-verifier"),
codeChallenge: vi.fn(() => "test-code-challenge"),
},
}));

// ---------------------------------------------------------------------------
// Imports (after all vi.mock() calls)
// ---------------------------------------------------------------------------
import { OAuthProviderError } from "./auth";

// ---------------------------------------------------------------------------
// OAuthProviderError class tests
// ---------------------------------------------------------------------------
describe("OAuthProviderError", () => {
it("defaults code to OAUTH_PROVIDER_ERROR", () => {
const err = new OAuthProviderError("Something went wrong");
expect(err.code).toBe("OAUTH_PROVIDER_ERROR");
expect(err.name).toBe("OAuthProviderError");
expect(err.message).toBe("Something went wrong");
});

it("accepts OAUTH_PROVIDER_UNAVAILABLE code", () => {
const err = new OAuthProviderError("Provider down", "OAUTH_PROVIDER_UNAVAILABLE");
expect(err.code).toBe("OAUTH_PROVIDER_UNAVAILABLE");
});

it("is an instance of Error", () => {
const err = new OAuthProviderError("test");
expect(err instanceof Error).toBe(true);
});
});

// ---------------------------------------------------------------------------
// triggerOauthFlow error handling tests
// ---------------------------------------------------------------------------
describe("triggerOauthFlow – provider failures", () => {
const makeEvent = () => ({
url: new URL("http://localhost:5173/login"),
locals: { sessionId: "test-session-id" },
cookies: {
set: vi.fn(),
get: vi.fn(),
getAll: vi.fn(() => []),
delete: vi.fn(),
serialize: vi.fn(),
},
request: new Request("http://localhost:5173/login"),
});

beforeEach(() => {
// Reset module cache to clear auth.ts's module-level lastIssuer cache.
vi.resetModules();
vi.clearAllMocks();
mockIssuerDiscover.mockReset();
});

it("returns 502 + OAUTH_PROVIDER_UNAVAILABLE on TypeError (fetch failed)", async () => {
mockIssuerDiscover.mockRejectedValue(new TypeError("fetch failed"));
const { triggerOauthFlow } = await import("./auth");
const response = await triggerOauthFlow(makeEvent() as never);

expect(response.status).toBe(502);
const body = await response.json();
expect(body.code).toBe("OAUTH_PROVIDER_UNAVAILABLE");
expect(typeof body.message).toBe("string");
});

it("returns 502 + OAUTH_PROVIDER_UNAVAILABLE on ECONNREFUSED", async () => {
mockIssuerDiscover.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:443"));
const { triggerOauthFlow } = await import("./auth");
const response = await triggerOauthFlow(makeEvent() as never);

expect(response.status).toBe(502);
const body = await response.json();
expect(body.code).toBe("OAUTH_PROVIDER_UNAVAILABLE");
});

it("returns 502 + OAUTH_PROVIDER_UNAVAILABLE on ENOTFOUND (DNS failure)", async () => {
mockIssuerDiscover.mockRejectedValue(
new Error("getaddrinfo ENOTFOUND provider.example.com")
);
const { triggerOauthFlow } = await import("./auth");
const response = await triggerOauthFlow(makeEvent() as never);

expect(response.status).toBe(502);
const body = await response.json();
expect(body.code).toBe("OAUTH_PROVIDER_UNAVAILABLE");
});

it("returns 502 + OAUTH_PROVIDER_ERROR on non-network provider error", async () => {
mockIssuerDiscover.mockRejectedValue(new Error("Invalid discovery document"));
const { triggerOauthFlow } = await import("./auth");
const response = await triggerOauthFlow(makeEvent() as never);

expect(response.status).toBe(502);
const body = await response.json();
expect(body.code).toBe("OAUTH_PROVIDER_ERROR");
});

it("has Content-Type application/json on error response", async () => {
mockIssuerDiscover.mockRejectedValue(new Error("provider error"));
const { triggerOauthFlow } = await import("./auth");
const response = await triggerOauthFlow(makeEvent() as never);

expect(response.headers.get("Content-Type")).toContain("application/json");
});

it("lets SvelteKit redirect pass through when provider is healthy", async () => {
mockIssuerDiscover.mockResolvedValue({
metadata: { id_token_signing_alg_values_supported: ["RS256"] },
Client: class {
authorizationUrl() {
return "https://provider.example.com/auth?code_challenge=xyz";
}
},
});

const { triggerOauthFlow } = await import("./auth");
await expect(triggerOauthFlow(makeEvent() as never)).rejects.toMatchObject({ status: 302 });
});
});

// ---------------------------------------------------------------------------
// getOIDCUserData error handling tests
// ---------------------------------------------------------------------------
describe("getOIDCUserData – provider failures", () => {
beforeEach(() => {
// Reset module cache to clear auth.ts's module-level lastIssuer cache.
vi.resetModules();
vi.clearAllMocks();
mockIssuerDiscover.mockReset();
});

it("throws OAuthProviderError OAUTH_PROVIDER_UNAVAILABLE on TypeError fetch failed", async () => {
mockIssuerDiscover.mockRejectedValue(new TypeError("fetch failed"));
const { getOIDCUserData } = await import("./auth");

await expect(
getOIDCUserData(
{ redirectURI: "http://localhost:5173/login/callback" },
"test-code",
"test-verifier",
undefined,
new URL("http://localhost:5173")
)
).rejects.toSatisfy(
(e: any) => e.name === "OAuthProviderError" && e.code === "OAUTH_PROVIDER_UNAVAILABLE"
);
});

it("throws OAuthProviderError OAUTH_PROVIDER_ERROR on token exchange error", async () => {
mockIssuerDiscover.mockResolvedValue({
metadata: { id_token_signing_alg_values_supported: ["RS256"] },
Client: class {
callback = vi.fn().mockRejectedValue(new Error("invalid_grant"));
userinfo = vi.fn();
},
});
const { getOIDCUserData } = await import("./auth");

await expect(
getOIDCUserData(
{ redirectURI: "http://localhost:5173/login/callback" },
"test-code",
"test-verifier",
undefined,
new URL("http://localhost:5173")
)
).rejects.toSatisfy(
(e: any) => e.name === "OAuthProviderError" && e.code === "OAUTH_PROVIDER_ERROR"
);
});
});
Loading
Loading