diff --git a/packages/integrations/src/base/session-store.ts b/packages/integrations/src/base/session-store.ts index 468440a0e..5c3ef0ce0 100644 --- a/packages/integrations/src/base/session-store.ts +++ b/packages/integrations/src/base/session-store.ts @@ -23,10 +23,15 @@ export const createSessionStore = (integration: { id: string }) => { return null; } }, - async setAsync(value: TValue) { - logger.debug("Updating session in store", { store: channelName }); + /** + * @param value session to store + * @param options pass `ttlSeconds` when the credential has a known lifetime, so a stored + * session can never outlive the token it holds + */ + async setAsync(value: TValue, options?: { ttlSeconds?: number }) { + logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds }); try { - await channel.setAsync(encryptSecret(superjson.stringify(value))); + await channel.setAsync(encryptSecret(superjson.stringify(value)), options); } catch (error) { logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error })); } diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index bc026fc18..a9599e216 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -1,5 +1,6 @@ import { ResponseError } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import type { Response as UndiciResponse } from "undici"; @@ -81,11 +82,47 @@ export const normalizeRealtimeSnapshot = ( return events; }; -interface BeszelSession { +export interface BeszelSession { token: string; userId: string; + /** Epoch milliseconds, taken from the token's `exp` claim. Undefined when it could not be read. */ + expiresAt?: number; } +/** + * Re-authenticate this long before the token actually lapses, so a request can never be sent + * with a token that expires while it is in flight. + */ +const sessionExpiryLeewayMs = 60_000; + +/** + * Reads the expiration out of a PocketBase auth token. + * + * @returns expiry as epoch milliseconds, or null when the token is malformed or carries no + * numeric `exp` claim — in which case we fall back to the existing 401 retry path. + */ +export const parseTokenExpiration = (token: string): number | null => { + const payload = token.split(".")[1]; + if (!payload) return null; + + try { + const decoded = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const { exp } = JSON.parse(decoded) as { exp?: unknown }; + return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null; + } catch { + return null; + } +}; + +/** + * Beszel answers requests carrying an expired token with `200` and an empty list rather than `401`, + * because its collection rules filter on `@request.auth.id` instead of rejecting. The 401 retry in + * `fetchWithAuthAsync` therefore never fires, so a stored session would otherwise be trusted + * forever. Sessions have to expire themselves. + */ +export const isSessionExpired = (session: BeszelSession, now = Date.now()) => + session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now; + export class BeszelIntegration extends Integration { private readonly sessionStore: SessionStore; @@ -96,11 +133,19 @@ export class BeszelIntegration extends Integration { private async authenticateAsync(): Promise { const existingSession = await this.sessionStore.getAsync(); - if (existingSession) { + if (existingSession && !isSessionExpired(existingSession)) { logger.debug("Using stored Beszel session", { integrationId: this.integration.id }); return existingSession; } + if (existingSession) { + logger.info("Stored Beszel session expired, re-authenticating", { + integrationId: this.integration.id, + expiresAt: existingSession.expiresAt, + }); + await this.sessionStore.clearAsync(); + } + const authUrl = this.url("/api/collections/users/auth-with-password"); logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname }); const response = await fetchWithTrustedCertificatesAsync(authUrl, { @@ -119,9 +164,30 @@ export class BeszelIntegration extends Integration { } const data = (await response.json()) as BeszelAuthResponse; - const session: BeszelSession = { token: data.token, userId: data.record.id }; - await this.sessionStore.setAsync(session); - logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId }); + const expiresAt = parseTokenExpiration(data.token) ?? undefined; + + // A token that has already lapsed by the time it reaches us means our clock and Beszel's + // disagree by more than the token lifetime. Re-authenticating would only mint another dead + // token, and using this one would silently produce the empty responses this check exists to + // prevent — so fail loudly instead. + if (expiresAt !== undefined && expiresAt <= Date.now()) { + logger.warn("Beszel issued an already-expired token", { integrationId: this.integration.id, expiresAt }); + throw new ErrorWithMetadata("Beszel issued an already-expired authentication token, check for clock skew", { + integrationId: this.integration.id, + expiresAt, + }); + } + + const session: BeszelSession = { token: data.token, userId: data.record.id, expiresAt }; + // Give the stored entry the token's own lifetime, so the cache cannot outlive the credential + // even if the session is never read again before it lapses. + const ttlSeconds = expiresAt === undefined ? undefined : Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)); + await this.sessionStore.setAsync(session, ttlSeconds === undefined ? undefined : { ttlSeconds }); + logger.debug("Saved Beszel session", { + integrationId: this.integration.id, + userId: session.userId, + expiresAt, + }); return session; } diff --git a/packages/integrations/src/beszel/test/beszel-session.spec.ts b/packages/integrations/src/beszel/test/beszel-session.spec.ts new file mode 100644 index 000000000..1e77dc4f1 --- /dev/null +++ b/packages/integrations/src/beszel/test/beszel-session.spec.ts @@ -0,0 +1,213 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; + process.env.ENABLE_DNS_CACHING = "false"; +}); + +// A stateful stand-in for redis, so a session actually survives between calls and the +// expiry logic can be exercised end to end. +const store = vi.hoisted(() => new Map()); + +vi.mock("@homarr/redis", () => ({ + createGetSetChannel: (name: string) => ({ + getAsync: () => Promise.resolve(store.get(name)?.value ?? null), + setAsync: (value: string, options?: { ttlSeconds?: number }) => { + store.set(name, { value, ttlSeconds: options?.ttlSeconds }); + return Promise.resolve(); + }, + removeAsync: () => { + store.delete(name); + return Promise.resolve(); + }, + }), +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + ErrorWithMetadata: class extends Error {}, +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init), + createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}), + createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: vi.fn() }), +})); + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), + getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), +})); + +import { BeszelIntegration, isSessionExpired, parseTokenExpiration } from "../beszel-integration"; + +const base64Url = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); +const createToken = (expiresAtSeconds: number) => `header.${base64Url({ exp: expiresAtSeconds })}.signature`; + +describe("parseTokenExpiration", () => { + test("reads the exp claim as epoch milliseconds", () => { + expect(parseTokenExpiration(createToken(1_800_000_000))).toBe(1_800_000_000_000); + }); + + test("decodes base64url payloads containing - and _", () => { + // A payload that base64-encodes with both + and /, which must be url-decoded first. + const token = `header.${Buffer.from(JSON.stringify({ sub: "a>b?c>>", exp: 1_700_000_000 })).toString("base64url")}.sig`; + expect(parseTokenExpiration(token)).toBe(1_700_000_000_000); + }); + + test.each([ + ["not-a-jwt", "no segments"], + ["header.%%%not-base64%%%.signature", "undecodable payload"], + [`header.${base64Url({ id: "user-1" })}.signature`, "no exp claim"], + [`header.${base64Url({ exp: "soon" })}.signature`, "non-numeric exp"], + ])("returns null for %s (%s)", (token) => { + expect(parseTokenExpiration(token)).toBeNull(); + }); +}); + +const session = (expiresAt?: number) => ({ token: "t", userId: "u", expiresAt }); + +describe("isSessionExpired", () => { + const now = Date.UTC(2026, 6, 26, 12, 0, 0); + + test("treats a session with an unknown expiry as valid, falling back to the 401 retry", () => { + expect(isSessionExpired(session(undefined), now)).toBe(false); + }); + + test("keeps a session that is comfortably in the future", () => { + expect(isSessionExpired(session(now + 60 * 60 * 1000), now)).toBe(false); + }); + + test("expires a session that lapses within the leeway window", () => { + expect(isSessionExpired(session(now + 30_000), now)).toBe(true); + }); + + test("expires a session that has already lapsed", () => { + expect(isSessionExpired(session(now - 1), now)).toBe(true); + }); +}); + +const createIntegration = () => + new BeszelIntegration({ + id: "test-beszel-session", + name: "Test Beszel", + url: "http://localhost:8090", + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: "user@example.com" }, + { kind: "password", value: "password" }, + ], + }); + +describe("BeszelIntegration session lifecycle", () => { + const originalFetch = globalThis.fetch; + let authCount = 0; + let issuedToken = ""; + + beforeEach(() => { + store.clear(); + authCount = 0; + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/collections/users/auth-with-password")) { + authCount++; + return Promise.resolve(Response.json({ token: issuedToken, record: { id: "user-1" } })); + } + if (url.includes("/api/collections/systems/records")) { + return Promise.resolve(Response.json({ page: 1, perPage: 500, totalItems: 0, totalPages: 0, items: [] })); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("reuses a stored session while its token is still valid", async () => { + issuedToken = createToken(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60); + const integration = createIntegration(); + + await integration.getSystemsAsync(); + await integration.getSystemsAsync(); + + expect(authCount).toBe(1); + }); + + test("persists the session with a ttl matching the token lifetime", async () => { + const lifetimeSeconds = 7 * 24 * 60 * 60; + issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds); + + await createIntegration().getSystemsAsync(); + + const stored = store.get("session-store:test-beszel-session"); + expect(stored?.ttlSeconds).toBeGreaterThan(lifetimeSeconds - 60); + expect(stored?.ttlSeconds).toBeLessThanOrEqual(lifetimeSeconds); + }); + + test("re-authenticates once the stored token has expired", async () => { + // Beszel replies 200 + empty list to an expired token instead of 401, so without an expiry + // check the stale session would be reused forever. See homarr-labs/homarr#6470. + vi.useFakeTimers(); + + try { + const lifetimeSeconds = 7 * 24 * 60 * 60; + const issuedAt = new Date("2026-07-26T00:00:00.000Z"); + vi.setSystemTime(issuedAt); + issuedToken = createToken(Math.floor(issuedAt.getTime() / 1000) + lifetimeSeconds); + const integration = createIntegration(); + + await integration.getSystemsAsync(); + expect(authCount).toBe(1); + + // Step past the token's lifetime, exactly as an idle Homarr instance would. + vi.setSystemTime(new Date(issuedAt.getTime() + (lifetimeSeconds + 1) * 1000)); + issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds); + await integration.getSystemsAsync(); + + expect(authCount).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + test("uses a short-lived token that is valid now but lapses inside the leeway window", async () => { + // The leeway exists to stop a token expiring mid-request; it must not reject a token that is + // still valid, or an instance with a short authToken.duration could never authenticate. + issuedToken = createToken(Math.floor(Date.now() / 1000) + 30); + + await expect(createIntegration().getSystemsAsync()).resolves.toEqual([]); + expect(authCount).toBe(1); + expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeGreaterThan(0); + }); + + test("fails loudly when Beszel issues an already-expired token instead of returning empty data", async () => { + issuedToken = createToken(Math.floor(Date.now() / 1000) - 600); + + const caught = await createIntegration() + .getSystemsAsync() + .then( + () => null, + (error: unknown) => error, + ); + + // The integration base class wraps thrown errors, so assert against the cause chain. + const messages: string[] = []; + for (let error: unknown = caught; error instanceof Error; error = error.cause) messages.push(error.message); + expect(messages.join(" | ")).toMatch(/clock skew/i); + + // The dead token must not be persisted, or it would be served to the next caller. + expect(store.has("session-store:test-beszel-session")).toBe(false); + expect(authCount).toBe(1); + }); + + test("does not store a ttl when the token carries no readable expiry", async () => { + issuedToken = "opaque-token-without-claims"; + + await createIntegration().getSystemsAsync(); + + expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeUndefined(); + }); +});