Compare commits

...
Author SHA1 Message Date
Thomas 86478adf7a Merge branch 'dev' into beszel-system-grid-layout-regression 2026-07-28 14:57:30 +02:00
homarr-releases[bot] 51a6cbe6d4 chore: update bug report template (#6464) 2026-07-28 14:56:56 +02:00
homarr-update-contributors[bot] d08eb2afa6 chore: update contributors (#6461) 2026-07-28 14:56:42 +02:00
Tim MooreandTim Moore 91a6b19ba1 fix(beszel): expire stored sessions so the integration recovers from token expiry (#6476)
Co-authored-by: Tim Moore <tim.moore@ingenuity.com.au>
2026-07-28 14:55:33 +02:00
Thomas e66979bab6 feat(auth): link credentials users with OIDC (#6445) 2026-07-28 14:49:51 +02:00
homarr-renovate[bot] 4ef7641cb2 chore(deps): update pnpm to v11.15.1 (#6466)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-25 02:43:47 +00:00
ajnart dafe1fdb75 fix(beszel): restore balanced grid layout for system grid widget
Reverts getColCount to the minimize-empty-cells heuristic from v1.68.0
after the aspect-ratio rewrite in 576856d63 broke 2×2 layout for 4
systems at common widget widths (240–359px).

Lowered MIN_CELL_WIDTH from 180 to 140 — a compromise between the
original 120 (v1.68.0) and the current 180, leaving room for the
DiskUsage component while keeping the 2×2 threshold at 280px.

Fixes #6385
2026-07-18 14:35:19 +02:00
18 changed files with 572 additions and 32 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.72.0
- 1.71.0
- 1.70.0
- 1.69.2
@@ -127,6 +127,8 @@ Homarr supports multiple authentication options, from internal userbase (credent
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
<details>
<summary>
Example Setup
@@ -204,6 +206,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
### Permission System
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.15.0"
"pnpm": ">=11.15.1"
},
"packageManager": "pnpm@11.15.0"
"packageManager": "pnpm@11.15.1"
}
+2 -2
View File
@@ -69,7 +69,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.15.0"
"pnpm": ">=11.15.1"
},
"packageManager": "pnpm@11.15.0"
"packageManager": "pnpm@11.15.1"
}
+24 -4
View File
@@ -6,20 +6,24 @@ import { and, eq } from "@homarr/db";
import { accounts, sessions, users } from "@homarr/db/schema";
import type { SupportedAuthProvider } from "@homarr/definitions";
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
export const createAdapter = (
db: Database,
provider: SupportedAuthProvider | "unknown",
enableDangerousCredentialsLinking = false,
): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
return {
...drizzleAdapter,
// We override the default implementation as we want to have a provider
// flag in the user instead of the account to not intermingle users from different providers
// Keep users isolated by their primary provider. The explicit credentials-linking
// opt-in is the only exception and keeps credentials as the primary provider.
// eslint-disable-next-line no-restricted-syntax
getUserByEmail: async (email) => {
if (provider === "unknown") {
throw new Error("Unable to get user by email for unknown provider");
}
const user = await db.query.users.findFirst({
let user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, provider)),
columns: {
id: true,
@@ -30,6 +34,22 @@ export const createAdapter = (db: Database, provider: SupportedAuthProvider | "u
},
});
if (!user && provider === "oidc" && enableDangerousCredentialsLinking) {
// Credentials accounts do not have an email-verification flow, so their
// Auth.js emailVerified field is always null. The OIDC profile's strict
// email_verified check is the security boundary for this opt-in.
user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, "credentials")),
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
});
}
if (!user) {
return null;
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const createConfiguration = (
headers: ReadonlyHeaders | null,
useSecureCookies: boolean,
) => {
const adapter = createAdapter(db, provider);
const adapter = createAdapter(db, provider, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
return NextAuth({
logger: {
error: (error) => {
+1
View File
@@ -46,6 +46,7 @@ export const env = createEnv({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: createBooleanSchema(false),
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
.default("client_secret_basic"),
+3 -1
View File
@@ -24,6 +24,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
name: true,
image: true,
colorScheme: true,
provider: true,
},
});
@@ -33,6 +34,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
// Groups from oidc provider are provided from the profile, it's not typed.
if (
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
dbUser.provider === "oidc" &&
profile &&
groupsKey in profile &&
Array.isArray(profile[groupsKey])
@@ -57,7 +59,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
});
}
if (profile) {
if (profile && dbUser.provider === "oidc") {
const profileUsername = extractProfileName(profile);
if (!profileUsername) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -7,6 +7,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
import { assertVerifiedEmailForCredentialsLinking } from "./verified-email";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
id: "oidc",
@@ -18,7 +19,8 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
},
issuer: env.AUTH_OIDC_ISSUER,
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
allowDangerousEmailAccountLinking:
env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING || env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING,
authorization: {
params: {
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
@@ -55,6 +57,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
}
assertVerifiedEmailForCredentialsLinking(profile, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
const name = extractProfileName(profile);
if (!name) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -0,0 +1,11 @@
export const assertVerifiedEmailForCredentialsLinking = (
profile: { email?: unknown; email_verified?: unknown },
isCredentialsLinkingEnabled: boolean,
) => {
if (
isCredentialsLinkingEnabled &&
(profile.email_verified !== true || typeof profile.email !== "string" || profile.email.trim().length === 0)
) {
throw new Error("OIDC provider did not return a verified email while credentials linking is enabled");
}
};
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { OidcProvider } from "../oidc/oidc-provider";
import { assertVerifiedEmailForCredentialsLinking } from "../oidc/verified-email";
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_CLIENT_ID: "client-id",
AUTH_OIDC_CLIENT_NAME: "OIDC",
AUTH_OIDC_CLIENT_SECRET: "client-secret",
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: false,
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: false,
AUTH_OIDC_FORCE_USERINFO: false,
AUTH_OIDC_ISSUER: "https://example.com",
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined,
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile",
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_basic" as const,
}));
vi.mock("../../env", () => ({ env: mockEnv }));
vi.mock("next-auth", () => ({ customFetch: Symbol("customFetch") }));
afterEach(() => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = false;
});
describe("assertVerifiedEmailForCredentialsLinking", () => {
test("allows a strictly verified OIDC email when credentials linking is enabled", () => {
expect(() =>
assertVerifiedEmailForCredentialsLinking({ email: "test@example.com", email_verified: true }, true),
).not.toThrow();
});
test.each([
{ email: "test@example.com", email_verified: false },
{ email: "test@example.com", email_verified: "true" },
{ email_verified: true },
{ email: null, email_verified: true },
{ email: " ", email_verified: true },
{},
])("rejects an OIDC profile without a strictly verified email when credentials linking is enabled", (profile) => {
expect(() => assertVerifiedEmailForCredentialsLinking(profile, true)).toThrow(
"OIDC provider did not return a verified email while credentials linking is enabled",
);
});
test("does not require the claim when credentials linking is disabled", () => {
expect(() => assertVerifiedEmailForCredentialsLinking({}, false)).not.toThrow();
});
test("OIDC provider rejects an unverified profile when credentials linking is enabled", () => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = true;
const provider = OidcProvider(null);
expect(provider.allowDangerousEmailAccountLinking).toBe(true);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).toThrow("OIDC provider did not return a verified email while credentials linking is enabled");
});
test("OIDC provider accepts an unverified profile when credentials linking is disabled", () => {
const provider = OidcProvider(null);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).not.toThrow();
});
});
+89 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { users } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { accounts, apiKeys, groupMembers, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createAdapter } from "../adapter";
@@ -64,4 +65,91 @@ describe("createAdapter should create drizzle adapter", () => {
// Assert
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
});
test("getUserByEmail should return a credentials user for oidc when credentials linking is enabled", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("credentials-user");
});
test("getUserByEmail should not return credentials users for non-oidc providers", async () => {
const db = createDb();
const adapter = createAdapter(db, "ldap", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user).toBeNull();
});
test("getUserByEmail should prefer an existing oidc user over a credentials user", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values([
{ id: "credentials-user", name: "credentials", email, provider: "credentials" },
{ id: "oidc-user", name: "oidc", email, provider: "oidc" },
]);
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("oidc-user");
});
test("linkAccount should preserve the credentials user and their data", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({
id: "credentials-user",
name: "test",
email,
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
await db.insert(groups).values({ id: "group", name: "Test group", position: 1 });
await db.insert(groupMembers).values({ groupId: "group", userId: "credentials-user" });
await db.insert(apiKeys).values({ id: "api-key", apiKey: "secret", userId: "credentials-user" });
const user = await adapter.getUserByEmail?.(email);
if (!user) throw new Error("Expected credentials user to be found for OIDC linking");
await adapter.linkAccount?.({
userId: user.id,
type: "oidc",
provider: "oidc",
providerAccountId: "oidc-subject",
});
const linkedUser = await db.query.users.findFirst({ where: eq(users.id, "credentials-user") });
const account = await db.query.accounts.findFirst({ where: eq(accounts.providerAccountId, "oidc-subject") });
const membership = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "credentials-user"),
});
const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys.userId, "credentials-user") });
const userByAccount = await adapter.getUserByAccount?.({
provider: "oidc",
providerAccountId: "oidc-subject",
});
const allUsers = await db.query.users.findMany();
expect(linkedUser).toMatchObject({
id: "credentials-user",
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
expect(account?.userId).toBe("credentials-user");
expect(membership?.groupId).toBe("group");
expect(apiKey?.id).toBe("api-key");
expect(userByAccount?.id).toBe("credentials-user");
expect(allUsers).toHaveLength(1);
});
});
+39 -7
View File
@@ -135,7 +135,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
@@ -155,7 +155,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
@@ -179,7 +179,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
@@ -204,7 +204,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
// Arrange
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
@@ -229,10 +229,10 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
["ldap" as const, { name: "test-new" }, undefined],
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
])("signInEventHandler should update username for %s provider", async (provider, user, profile) => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, provider);
const eventHandler = createSignInEventHandler(db);
// Act
@@ -251,6 +251,32 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbUser?.name).toBe("test-new");
});
test("signInEventHandler should preserve credentials-owned data when the user signs in with linked oidc", async () => {
const db = createDb();
await createUserAsync(db, "credentials", "local-avatar");
await createGroupAsync(db);
await db.insert(groupMembers).values({ userId: "1", groupId: "1" });
const eventHandler = createSignInEventHandler(db);
await eventHandler?.({
user: { id: "1", name: "test" },
profile: {
preferred_username: "oidc-name",
picture: "oidc-avatar",
someRandomGroupsKey: [],
},
account: null,
});
const dbUser = await db.query.users.findFirst({ where: eq(users.id, "1") });
const membership = await db.query.groupMembers.findFirst({ where: eq(groupMembers.userId, "1") });
expect(dbUser).toMatchObject({
name: "test",
image: "local-avatar",
provider: "credentials",
});
expect(membership?.groupId).toBe("1");
});
test("signInEventHandler should set color-scheme cookie", async () => {
// Arrange
const db = createDb();
@@ -275,11 +301,17 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
});
const createUserAsync = async (db: Database) =>
const createUserAsync = async (
db: Database,
provider: "credentials" | "ldap" | "oidc" = "credentials",
image?: string,
) =>
await db.insert(users).values({
id: "1",
name: "test",
colorScheme: "dark",
image,
provider,
});
const createGroupAsync = async (db: Database, name = "test") =>
@@ -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();
});
});
@@ -114,14 +114,22 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 180;
const MIN_CELL_WIDTH = 140;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, height: number, itemCount: number): number => {
const getColCount = (width: number, _height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
};
interface MetricRowProps {
+1 -1
View File
@@ -1 +1 @@
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowLine truncated
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowLine truncated