fix(beszel): expire stored sessions so the integration recovers from token expiry (#6476)
Co-authored-by: Tim Moore <tim.moore@ingenuity.com.au>
This commit is contained in:
1 parent
e66979bab6
commit
91a6b19ba1
3 files changed
+292
-8
No files matched your search
@@ -23,10 +23,15 @@ export const createSessionStore = <TValue>(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 }));
|
||||
}
|
||||
|
||||
@@ -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<BeszelSession>;
|
||||
|
||||
@@ -96,11 +133,19 @@ export class BeszelIntegration extends Integration {
|
||||
|
||||
private async authenticateAsync(): Promise<BeszelSession> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, { value: string; ttlSeconds?: number }>());
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user