feat(auth): add generic OIDC login with mandatory groups
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { beginOidcLogin, logout } from "./auth";
|
||||
import { server } from "../test/msw";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
|
||||
const user = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const,
|
||||
permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((onResolve) => { resolve = onResolve; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAuthState();
|
||||
setAuthState(user);
|
||||
});
|
||||
|
||||
test("OIDC navigation waits for a successful pending logout response", async () => {
|
||||
const gate = deferred();
|
||||
let logoutStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => { logoutStarted = resolve; });
|
||||
server.use(http.post("/api/auth/logout", async () => {
|
||||
logoutStarted();
|
||||
await gate.promise;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}));
|
||||
const logoutPromise = logout();
|
||||
await started;
|
||||
const navigate = vi.fn();
|
||||
const oidc = beginOidcLogin(navigate);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
gate.resolve();
|
||||
await Promise.all([logoutPromise, oidc]);
|
||||
expect(navigate).toHaveBeenCalledOnce();
|
||||
expect(navigate).toHaveBeenCalledWith("/api/auth/oidc/login");
|
||||
});
|
||||
|
||||
test("OIDC navigation waits for a failed pending logout response before continuing", async () => {
|
||||
const gate = deferred();
|
||||
let logoutStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => { logoutStarted = resolve; });
|
||||
server.use(http.post("/api/auth/logout", async () => {
|
||||
logoutStarted();
|
||||
await gate.promise;
|
||||
return HttpResponse.json({ code: "auth_unavailable" }, { status: 503 });
|
||||
}));
|
||||
const logoutPromise = logout().catch(() => undefined);
|
||||
await started;
|
||||
const navigate = vi.fn();
|
||||
const oidc = beginOidcLogin(navigate);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
gate.resolve();
|
||||
await Promise.all([logoutPromise, oidc]);
|
||||
expect(navigate).toHaveBeenCalledWith("/api/auth/oidc/login");
|
||||
});
|
||||
@@ -6,6 +6,7 @@ const authModes = new Set<AuthPublicConfig["mode"]>(["local", "oidc", "upstream"
|
||||
const roles = new Set<AuthRole>(["user", "admin"]);
|
||||
const sessionMethods = new Set<AuthSessionInfo["method"]>(["local", "oidc", "upstream"]);
|
||||
let pendingLogoutResponse: Promise<void> | null = null;
|
||||
const oidcLoginPath = "/api/auth/oidc/login";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -86,6 +87,23 @@ export async function loginLocal(username: string, password: string, remember: b
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC creates its browser session through a top-level same-origin navigation. A previous logout
|
||||
* response may still carry a clearing Set-Cookie, so it must settle before this navigation begins.
|
||||
*/
|
||||
export async function beginOidcLogin(navigate: (path: string) => void = (path) => window.location.assign(path)): Promise<void> {
|
||||
const pending = pendingLogoutResponse;
|
||||
if (pending) {
|
||||
try {
|
||||
await pending;
|
||||
} catch {
|
||||
// A failed logout must release the coordinator; the current browser cookie remains the
|
||||
// backend's authority during the following OIDC handshake.
|
||||
}
|
||||
}
|
||||
navigate(oidcLoginPath);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<boolean> {
|
||||
const logoutGeneration = getAuthGeneration();
|
||||
if (pendingLogoutResponse) {
|
||||
|
||||
@@ -76,14 +76,12 @@ describe("LoginPage", () => {
|
||||
expect(screen.getByLabelText(/password/i)).toHaveValue("");
|
||||
});
|
||||
|
||||
test("shows OIDC only when public configuration enables it and uses same-origin navigation", () => {
|
||||
test("shows OIDC only when public configuration enables it", () => {
|
||||
const { rerender } = render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
|
||||
expect(screen.queryByRole("link", { name: /single sign-on/i })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<LoginPage config={oidcConfig} onAuthenticated={vi.fn()} />);
|
||||
expect(screen.getByRole("link", { name: /single sign-on/i })).toHaveAttribute(
|
||||
"href", "/api/auth/oidc/login",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /single sign-on/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("does not dispatch local login until an in-flight logout response settles", async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { AlertTriangle, ArrowRight, LockKeyhole } from "lucide-react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { loginLocal } from "../api/auth";
|
||||
import { beginOidcLogin, loginLocal } from "../api/auth";
|
||||
import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
@@ -24,6 +24,7 @@ function loginError(error: unknown): { message: string; retry: boolean } {
|
||||
|
||||
export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) {
|
||||
const localLogin = config.mode === "local" && config.localLogin;
|
||||
const oidcLogin = config.mode === "oidc" && config.oidcLogin;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -65,6 +66,10 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
|
||||
}
|
||||
}
|
||||
|
||||
function startOidcLogin() {
|
||||
void beginOidcLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 sm:py-12">
|
||||
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-5xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_26rem]">
|
||||
@@ -138,6 +143,12 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{oidcLogin && (
|
||||
<Button type="button" size="lg" className="w-full" onClick={startOidcLogin}>
|
||||
Continue with single sign-on
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{config.oidcLogin && (
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { server } from "../test/msw";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
function renderShell() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((onResolve) => { resolve = onResolve; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAuthState();
|
||||
setAuthState({
|
||||
issuer: "local", subject: "user-a", roles: ["user"], permissions: ["session.use"],
|
||||
isAdmin: false, csrfToken: null, session: null,
|
||||
});
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as { EventSource: typeof EventSource }).EventSource = FakeEventSource as unknown as typeof EventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
server.use(
|
||||
http.get("/api/me", () => HttpResponse.json({ issuer: "local", subject: "user-a", isAdmin: false })),
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("a held stop for s1 cannot reset the newer active s2 session", async () => {
|
||||
const closeGate = deferred();
|
||||
const closeStarted = deferred();
|
||||
const active = (id: string, question: string) => ({
|
||||
id, status: "open", question, summary: null, created_at: "2026-01-02T00:00:00Z",
|
||||
updated_at: null, author: null, name: null, group: null, archived: false, active: true,
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([active("s1", "Active one"), active("s2", "Active two")])),
|
||||
http.post("/api/sessions/:id/resume", ({ params }) => HttpResponse.json({ id: params.id, alreadyActive: false })),
|
||||
http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: 1 })),
|
||||
http.post("/api/sessions/s1/close", async () => {
|
||||
closeStarted.resolve();
|
||||
await closeGate.promise;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
renderShell();
|
||||
await userEvent.click(await screen.findByTestId("session-item-s1"));
|
||||
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /stop and save session/i }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Stop & save" }));
|
||||
await closeStarted.promise;
|
||||
|
||||
await userEvent.click(screen.getByTestId("session-item-s2"));
|
||||
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s2/events"));
|
||||
act(() => useSessionStore.setState({ currentPhase: "F2" }));
|
||||
closeGate.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s2/events");
|
||||
expect(FakeEventSource.instances.at(-1)?.closed).toBe(false);
|
||||
expect(useSessionStore.getState().currentPhase).toBe("F2");
|
||||
});
|
||||
|
||||
test("a held new-session completion cannot replace the newer active s2 target", async () => {
|
||||
const createGate = deferred();
|
||||
const createStarted = deferred();
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([{
|
||||
id: "s2", status: "open", question: "Current session", summary: null,
|
||||
created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null,
|
||||
group: null, archived: false, active: true,
|
||||
}])),
|
||||
http.post("/api/sessions", async () => {
|
||||
createStarted.resolve();
|
||||
await createGate.promise;
|
||||
return HttpResponse.json({ id: "s3" });
|
||||
}),
|
||||
http.post("/api/sessions/:id/resume", ({ params }) => HttpResponse.json({ id: params.id, alreadyActive: false })),
|
||||
http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: 1 })),
|
||||
);
|
||||
renderShell();
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Held new question");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
await createStarted.promise;
|
||||
|
||||
await userEvent.click(screen.getByTestId("session-item-s2"));
|
||||
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s2/events"));
|
||||
act(() => useSessionStore.setState({ currentPhase: "F2" }));
|
||||
createGate.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s2/events");
|
||||
expect(FakeEventSource.instances.at(-1)?.closed).toBe(false);
|
||||
expect(useSessionStore.getState().currentPhase).toBe("F2");
|
||||
});
|
||||
@@ -80,6 +80,8 @@ export function AppShell() {
|
||||
} as CSSProperties;
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
const activeSessionEpochRef = useRef(0);
|
||||
const newSessionOperationRef = useRef<{ target: string | null; epoch: number } | null>(null);
|
||||
const resumeInvocationRef = useRef(0);
|
||||
const latestResumeIntentRef = useRef<{ token: number; id: string } | null>(null);
|
||||
const resumeInFlightRef = useRef(new Map<string, {
|
||||
@@ -165,6 +167,7 @@ export function AppShell() {
|
||||
|
||||
function selectActiveSession(id: string | null) {
|
||||
// Keep async Resume completions synchronized before React commits the state update.
|
||||
if (activeSessionIdRef.current !== id) activeSessionEpochRef.current += 1;
|
||||
activeSessionIdRef.current = id;
|
||||
setActiveSessionId(id);
|
||||
}
|
||||
@@ -505,6 +508,7 @@ export function AppShell() {
|
||||
|
||||
function startNewSession() {
|
||||
invalidateResumeIntent();
|
||||
newSessionOperationRef.current = null;
|
||||
resetSession();
|
||||
// Starting a new question closes any open session detail panel: the reader is
|
||||
// moving away from that session, so its left-hand box must not linger.
|
||||
@@ -519,11 +523,18 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
function beginSessionCreation() {
|
||||
newSessionOperationRef.current = {
|
||||
target: activeSessionIdRef.current,
|
||||
epoch: activeSessionEpochRef.current,
|
||||
};
|
||||
setAwaitingQuestion(false);
|
||||
setCreatingSession(true);
|
||||
}
|
||||
|
||||
function finishSessionCreation(id: string) {
|
||||
const operation = newSessionOperationRef.current;
|
||||
newSessionOperationRef.current = null;
|
||||
if (!operation || operation.target !== activeSessionIdRef.current || operation.epoch !== activeSessionEpochRef.current) return;
|
||||
// React batches these updates, preserving the provisional session view
|
||||
// while useSessionStream opens the durable session's SSE channel.
|
||||
selectActiveSession(id);
|
||||
@@ -533,21 +544,25 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
function failSessionCreation(message?: string) {
|
||||
const operation = newSessionOperationRef.current;
|
||||
newSessionOperationRef.current = null;
|
||||
if (!operation || operation.target !== activeSessionIdRef.current || operation.epoch !== activeSessionEpochRef.current) return;
|
||||
setCreatingSession(false);
|
||||
resetSession();
|
||||
toast.error(message ?? "Failed to create session. Your question is ready to retry.");
|
||||
}
|
||||
|
||||
async function stopSession() {
|
||||
if (!activeSessionId) return;
|
||||
const id = activeSessionId;
|
||||
const id = activeSessionIdRef.current;
|
||||
if (!id) return;
|
||||
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
|
||||
if (!guard) return;
|
||||
invalidateResumeIntent();
|
||||
try {
|
||||
await closeSession(id);
|
||||
} finally {
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })
|
||||
|| activeSessionIdRef.current !== id) return;
|
||||
resetSession();
|
||||
selectActiveSession(null);
|
||||
setAwaitingQuestion(false);
|
||||
@@ -643,7 +658,7 @@ export function AppShell() {
|
||||
</header>
|
||||
)}
|
||||
<CentralStatus working={working} />
|
||||
{activeSessionId && <WidgetHost sessionId={activeSessionId} />}
|
||||
{activeSessionId && <WidgetHost key={`widget:${activeSessionId}:${activeSessionEpochRef.current}`} sessionId={activeSessionId} />}
|
||||
{finalized && !agentActive && (
|
||||
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -668,6 +683,7 @@ export function AppShell() {
|
||||
<div className="rounded-2xl border border-border/80 bg-card shadow-md transition-colors focus-within:border-primary/50 focus-within:ring-3 focus-within:ring-ring/15">
|
||||
<div className="px-2.5 py-2">
|
||||
<SteerInput
|
||||
key={`steer:${activeSessionId ?? "new"}:${activeSessionEpochRef.current}`}
|
||||
sessionId={activeSessionId}
|
||||
onSessionCreating={beginSessionCreation}
|
||||
onSessionCreated={finishSessionCreation}
|
||||
|
||||
@@ -113,6 +113,38 @@ test("a delayed steer from user A cannot mutate user B's store or composer", asy
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("a held steer for s1 cannot complete into the active s2 operation scope", async () => {
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(http.post("/api/sessions/s1/steer", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}));
|
||||
|
||||
const view = render(<SteerInput sessionId="s1" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
await userEvent.type(input, "Keep s1 isolated");
|
||||
await userEvent.click(screen.getByRole("button", { name: /invia|send|steer/i }));
|
||||
await requestStarted;
|
||||
|
||||
view.rerender(<SteerInput sessionId="s2" />);
|
||||
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "s2 owned" }));
|
||||
release();
|
||||
await act(async () => { await requestSettled; });
|
||||
|
||||
expect(useSessionStore.getState().lastUserEntry).toEqual({ kind: "input", text: "s2 owned" });
|
||||
expect(input).toHaveValue("Keep s1 isolated");
|
||||
});
|
||||
|
||||
test("a settings preflight from user A prevents session POST after user B logs in", async () => {
|
||||
let releaseSettings!: () => void;
|
||||
let settingsStarted!: () => void;
|
||||
|
||||
@@ -51,6 +51,12 @@ export function SteerInput({
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const operationEpochRef = useRef(0);
|
||||
useEffect(() => () => { operationEpochRef.current += 1; }, []);
|
||||
useEffect(() => {
|
||||
// A new active session owns a new composer operation scope. Invalidate any held request
|
||||
// before it can write the old session's completion into the new target.
|
||||
operationEpochRef.current += 1;
|
||||
setBusy(false);
|
||||
}, [sessionId]);
|
||||
|
||||
// Merge our own ref (for autosizing) with the forwarded one (parent focus).
|
||||
function attachRef(el: HTMLTextAreaElement | null) {
|
||||
|
||||
@@ -92,3 +92,37 @@ test("a delayed gate response from user A cannot clear user B's pending gate", a
|
||||
expect(useSessionStore.getState().pendingWidget?.id).toBe("gate-b");
|
||||
expect(useSessionStore.getState().lastUserEntry).toBeNull();
|
||||
});
|
||||
|
||||
test("a held s1 gate response cannot clear the pending s2 gate", async () => {
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(http.post("/api/sessions/s1/response", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}));
|
||||
useSessionStore.setState({
|
||||
pendingWidget: { id: "gate-s1", widget: "select", options: [{ id: "s1", label: "Answer s1" }] },
|
||||
});
|
||||
const view = render(<WidgetHost sessionId="s1" />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Answer s1" }));
|
||||
await requestStarted;
|
||||
|
||||
view.rerender(<WidgetHost sessionId="s2" />);
|
||||
act(() => useSessionStore.setState({
|
||||
pendingWidget: { id: "gate-s2", widget: "select", options: [{ id: "s2", label: "Answer s2" }] },
|
||||
}));
|
||||
release();
|
||||
await act(async () => { await requestSettled; });
|
||||
|
||||
expect(useSessionStore.getState().pendingWidget?.id).toBe("gate-s2");
|
||||
expect(useSessionStore.getState().lastUserEntry).toBeNull();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,12 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
|
||||
const responseInFlight = useRef(false);
|
||||
const operationEpochRef = useRef(0);
|
||||
useEffect(() => () => { operationEpochRef.current += 1; }, []);
|
||||
useEffect(() => {
|
||||
// A gate belongs to its active session target, not merely the authenticated user.
|
||||
operationEpochRef.current += 1;
|
||||
responseInFlight.current = false;
|
||||
setResponding(false);
|
||||
}, [sessionId]);
|
||||
if (!pending) return null;
|
||||
const Renderer = resolve(pending.widget);
|
||||
const onRespond = async (r: UiResponse) => {
|
||||
|
||||
Reference in New Issue
Block a user