Skip to content

Commit 14eba00

Browse files
committed
fix(ui): auth on diagnostics widget
1 parent 2d450bf commit 14eba00

6 files changed

Lines changed: 87 additions & 18 deletions

File tree

frontend/src/api/authenticated-sse.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1+
import { authenticatedFetch } from "@/api/client"
2+
13
export type SseMessage = { event: string; data: string }
24

35
export async function consumeAuthenticatedSse(
46
url: string,
57
signal: AbortSignal,
68
onMessage: (message: SseMessage) => void
79
) {
8-
const token = sessionStorage.getItem("keen-pbr-auth-token")
9-
const response = await fetch(url, {
10+
const response = await authenticatedFetch(url, {
1011
signal,
11-
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
1212
})
13-
if (response.status === 401) {
14-
sessionStorage.removeItem("keen-pbr-auth-token")
15-
window.dispatchEvent(new Event("keen-pbr-auth-required"))
16-
}
17-
if (!response.ok || !response.body) throw new Error(`SSE request failed (${response.status})`)
13+
if (!response.ok || !response.body)
14+
throw new Error(`SSE request failed (${response.status})`)
1815

1916
const reader = response.body.getReader()
2017
const decoder = new TextDecoder()

frontend/src/api/client.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,30 @@ const normalizeError = (status: number, payload: unknown): ApiError => {
3737
}
3838
}
3939

40-
export const apiFetch = async <T>(
40+
export const authenticatedFetch = async (
4141
url: string,
42-
options: RequestInit
43-
): Promise<T> => {
42+
options: RequestInit = {}
43+
): Promise<Response> => {
4444
const token = sessionStorage.getItem("keen-pbr-auth-token")
4545
const headers = new Headers(options.headers)
4646
if (token) headers.set("Authorization", `Bearer ${token}`)
47+
4748
const response = await fetch(url, { ...options, headers })
49+
if (response.status === 401) {
50+
sessionStorage.removeItem("keen-pbr-auth-token")
51+
window.dispatchEvent(new Event("keen-pbr-auth-required"))
52+
}
53+
return response
54+
}
55+
56+
export const apiFetch = async <T>(
57+
url: string,
58+
options: RequestInit
59+
): Promise<T> => {
60+
const response = await authenticatedFetch(url, options)
4861
const payload = await parseResponsePayload(response)
4962

5063
if (!response.ok) {
51-
if (response.status === 401) {
52-
sessionStorage.removeItem("keen-pbr-auth-token")
53-
window.dispatchEvent(new Event("keen-pbr-auth-required"))
54-
}
5564
throw normalizeError(response.status, payload)
5665
}
5766

frontend/src/auth/auth-context.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { createContext, useContext, useEffect, useMemo, useState } from "react"
33

44
import { getDevicePageTitle } from "@/auth/device-name"
5+
import { authenticatedFetch } from "@/api/client"
56

67
type AuthState = {
78
enabled: boolean
@@ -59,9 +60,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
5960
setState((current) => ({ ...current, enabled: true, authenticated: true, loading: false }))
6061
},
6162
logout: async () => {
62-
const token = sessionStorage.getItem(tokenKey)
6363
try {
64-
await fetch("/api/auth/logout", { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : undefined })
64+
await authenticatedFetch("/api/auth/logout", { method: "POST" })
6565
} finally {
6666
sessionStorage.removeItem(tokenKey)
6767
setState((current) => ({ ...current, enabled: true, authenticated: false, loading: false }))

frontend/src/components/overview/diagnostics-download-dialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
DialogTitle,
1919
} from "@/components/ui/dialog"
2020
import { Label } from "@/components/ui/label"
21+
import { authenticatedFetch } from "@/api/client"
2122

2223
export function DiagnosticsDownloadDialog({
2324
open,
@@ -153,7 +154,9 @@ function redactConfigLists(config: ConfigObject): ConfigObject {
153154
async function downloadDiagnosticsFile(payload: Record<string, unknown>) {
154155
let commandFailureLog: string | undefined
155156
try {
156-
const response = await fetch("/api/diagnostics/command-failure")
157+
const response = await authenticatedFetch(
158+
"/api/diagnostics/command-failure"
159+
)
157160
if (response.status === 200) {
158161
commandFailureLog = await response.text()
159162
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
3+
import { authenticatedFetch } from "../src/api/client"
4+
5+
const originalFetch = globalThis.fetch
6+
const originalSessionStorage = globalThis.sessionStorage
7+
8+
afterEach(() => {
9+
Object.defineProperty(globalThis, "fetch", {
10+
configurable: true,
11+
value: originalFetch,
12+
})
13+
Object.defineProperty(globalThis, "sessionStorage", {
14+
configurable: true,
15+
value: originalSessionStorage,
16+
})
17+
})
18+
19+
describe("authenticatedFetch", () => {
20+
test("adds the active UI Bearer token while preserving caller headers", async () => {
21+
const values = new Map([["keen-pbr-auth-token", "test-token"]])
22+
Object.defineProperty(globalThis, "sessionStorage", {
23+
configurable: true,
24+
value: {
25+
getItem: (key: string) => values.get(key) ?? null,
26+
setItem: (key: string, value: string) => values.set(key, value),
27+
removeItem: (key: string) => values.delete(key),
28+
},
29+
})
30+
31+
let receivedHeaders: Headers | undefined
32+
Object.defineProperty(globalThis, "fetch", {
33+
configurable: true,
34+
value: (_url: string, options?: RequestInit) => {
35+
receivedHeaders = new Headers(options?.headers)
36+
return Promise.resolve(new Response(null, { status: 204 }))
37+
},
38+
})
39+
40+
const response = await authenticatedFetch(
41+
"/api/diagnostics/command-failure",
42+
{
43+
headers: { Accept: "text/plain" },
44+
}
45+
)
46+
47+
expect(response.status).toBe(204)
48+
expect(receivedHeaders?.get("Authorization")).toBe("Bearer test-token")
49+
expect(receivedHeaders?.get("Accept")).toBe("text/plain")
50+
})
51+
})

tests/test_api_auth.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,22 @@ TEST_CASE("API accepts Basic auth and keeps only the newest Bearer session") {
4242
config.authentication->password_hash = auth::generate_password_hash("secret");
4343
ApiServer server(config);
4444
server.get("/api/protected", [] { return std::string("{\"ok\":true}"); });
45+
server.get_stream("/api/protected-stream",
46+
[](const httplib::Request&, httplib::Response& response) {
47+
response.set_content("protected stream", "text/plain");
48+
});
4549
server.start();
4650
httplib::Client client("127.0.0.1", 18193);
4751

4852
CHECK(client.Get("/api/protected")->status == 401);
53+
CHECK(client.Get("/api/protected-stream")->status == 401);
4954
const auto basic = client.Get("/api/protected", httplib::Headers{{"Authorization", "Basic YWRtaW46c2VjcmV0"}});
5055
REQUIRE(basic != nullptr);
5156
CHECK(basic->status == 200);
57+
const auto basic_stream = client.Get(
58+
"/api/protected-stream", httplib::Headers{{"Authorization", "Basic YWRtaW46c2VjcmV0"}});
59+
REQUIRE(basic_stream != nullptr);
60+
CHECK(basic_stream->status == 200);
5261

5362
const auto login1 = client.Post("/api/auth/login", "{\"password\":\"secret\"}", "application/json");
5463
REQUIRE(login1 != nullptr);

0 commit comments

Comments
 (0)