feat: add Keycloak authentication
This commit is contained in:
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the current Keycloak access token without dropping request headers", async () => {
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
|
||||
|
||||
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
|
||||
|
||||
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
if (!init) throw new Error("Expected Agent request init");
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("streams session events from the backend SSE endpoint", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
|
||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||
|
||||
@@ -112,19 +113,47 @@ export type AgentApiClient = {
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type AgentApiClientOptions = {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
|
||||
const CHAT_PATH = "/api/v1/agent/chat";
|
||||
|
||||
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
|
||||
export function createAgentApiClient(
|
||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||
options: AgentApiClientOptions = {}
|
||||
): AgentApiClient {
|
||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||
let activeBaseUrl = candidates[0] ?? "";
|
||||
const setActiveBaseUrl = (baseUrl: string) => {
|
||||
activeBaseUrl = baseUrl;
|
||||
};
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
setActiveBaseUrl,
|
||||
path,
|
||||
init,
|
||||
options.getAccessToken
|
||||
);
|
||||
const requestJson = async <T,>(path: string, init?: RequestInit) => {
|
||||
const response = await request(path, init);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return data as T;
|
||||
};
|
||||
|
||||
return {
|
||||
async createSession() {
|
||||
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/session", {
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
"/sessions"
|
||||
);
|
||||
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
|
||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||
},
|
||||
|
||||
async getFrontendActionRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
|
||||
return requestJson<unknown>("/frontend-action-registry");
|
||||
},
|
||||
|
||||
async submitFrontendActionResult(sessionId, actionId, result) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
||||
body: JSON.stringify(result)
|
||||
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async loadSession(sessionId) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/session/${encodeURIComponent(sessionId)}`);
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async streamSession(sessionId, options) {
|
||||
const response = await fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
const response = await request(
|
||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
return;
|
||||
}
|
||||
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||
{
|
||||
method: "PATCH",
|
||||
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async deleteSession(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
},
|
||||
|
||||
async getModels() {
|
||||
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/models");
|
||||
const payload = await requestJson<unknown>("/models");
|
||||
return toModelsResponse(payload);
|
||||
},
|
||||
|
||||
async getUiRegistry() {
|
||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/ui-registry");
|
||||
return requestJson<unknown>("/ui-registry");
|
||||
},
|
||||
|
||||
async resolveRenderRef(renderRef, sessionId) {
|
||||
const params = new URLSearchParams({ session_id: sessionId });
|
||||
return requestJsonWithFallback<unknown>(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
(nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
},
|
||||
return requestJson<unknown>(
|
||||
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||
);
|
||||
},
|
||||
|
||||
async replyPermission(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async replyQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async rejectQuestion(requestId, options) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
},
|
||||
|
||||
async abort(sessionId) {
|
||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
}, "/abort", {
|
||||
await requestJson<unknown>("/abort", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJsonWithFallback<T>(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
) {
|
||||
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function fetchWithFallback(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
let lastResponse: Response | null = null;
|
||||
const accessToken = await getAccessToken?.();
|
||||
const requestInit = accessToken
|
||||
? {
|
||||
...init,
|
||||
headers: withBearerToken(init?.headers, accessToken)
|
||||
}
|
||||
: init;
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
@@ -359,6 +337,12 @@ async function fetchWithFallback(
|
||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||
}
|
||||
|
||||
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
|
||||
const headers = new Headers(headersInit);
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function shouldFallbackOnHttpStatus(status: number) {
|
||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export { AgentPersona } from "./components/agent-persona";
|
||||
export { createAgentApiClient } from "./api/client";
|
||||
export type {
|
||||
AgentApiClient,
|
||||
AgentApiClientOptions,
|
||||
AgentChatSessionSummary,
|
||||
AgentLoadedChatSession,
|
||||
AgentSessionStreamEvent
|
||||
|
||||
Reference in New Issue
Block a user