refactor(auth): mark desktop sessions with auth source (#990)

* refactor(auth): mark desktop sessions with auth source

Makes it easier to filter out on session type in backend paths that
behave differently depending on the context

* chore: fix un-used import

* fix(auth): align desktop session guards

* refactor(auth): gate desktop sessions by runtime features
This commit is contained in:
Nico
2026-06-15 21:39:32 +02:00
committed by GitHub
parent d1e96c21b9
commit d24167b520
29 changed files with 4086 additions and 96 deletions

View File

@@ -62,7 +62,9 @@ export function DownloadRecoveryKeyPage({ passwordAuthSupported, hasPassword, us
}
setBlockedMessage(null);
downloadResticPassword.mutate({ body: { password: passwordAuthSupported ? password : "" } });
downloadResticPassword.mutate({
body: { password: passwordAuthSupported ? password : "" },
});
};
const handleSkip = () => {

View File

@@ -271,10 +271,12 @@ export function SettingsPage({
</div>
</CardContent>
<PendingInvitationsSection
initialInvitations={initialUserInvitations}
userEmail={appContext.user?.email}
/>
{permissions.hasRuntimeFeature("ssoManagement") && (
<PendingInvitationsSection
initialInvitations={initialUserInvitations}
userEmail={appContext.user?.email}
/>
)}
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
@@ -488,7 +490,12 @@ export function SettingsPage({
</Dialog>
</CardContent>
<ApiKeysSection passwordAuthSupported={passwordAuthSupported} hasPassword={hasPassword} />
{permissions.hasRuntimeFeature("apiKeys") && (
<ApiKeysSection
passwordAuthSupported={passwordAuthSupported}
hasPassword={hasPassword}
/>
)}
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />

View File

@@ -0,0 +1 @@
ALTER TABLE `sessions_table` ADD `auth_source` text DEFAULT 'browser-session' NOT NULL;

View File

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,40 @@ describe("permissions", () => {
evaluatePermission("ssoProvider.create", {
runtime: "server",
orgRole: "owner",
authSource: "desktop-session",
}),
).toEqual({ allowed: false, reason: "authSource" });
expect(
evaluatePermission("ssoProvider.create", {
runtime: "server",
orgRole: "owner",
authSource: "api-key",
}),
).toEqual({ allowed: false, reason: "authSource" });
});
test("allows recovery-key download for browser and desktop sessions but not API keys", () => {
expect(
evaluatePermission("recoveryKey.download", {
runtime: "desktop",
orgRole: "owner",
authSource: "desktop-session",
}).allowed,
).toBe(true);
expect(
evaluatePermission("recoveryKey.download", {
runtime: "server",
orgRole: "owner",
authSource: "browser-session",
}).allowed,
).toBe(true);
expect(
evaluatePermission("recoveryKey.download", {
runtime: "desktop",
orgRole: "owner",
authSource: "api-key",
}),
).toEqual({ allowed: false, reason: "authSource" });
@@ -80,5 +114,9 @@ describe("permissions", () => {
test("models runtime features independently from user roles", () => {
expect(hasRuntimeFeature("server", "remoteVolumeBackends")).toBe(true);
expect(hasRuntimeFeature("desktop", "remoteVolumeBackends")).toBe(false);
expect(hasRuntimeFeature("server", "apiKeys")).toBe(true);
expect(hasRuntimeFeature("desktop", "apiKeys")).toBe(false);
expect(hasRuntimeFeature("server", "passwordAuthentication")).toBe(true);
expect(hasRuntimeFeature("desktop", "passwordAuthentication")).toBe(false);
});
});

View File

@@ -1,11 +1,13 @@
export type Runtime = "server" | "desktop";
export type AuthSource = "browser-session" | "api-key";
export type AuthSource = "browser-session" | "desktop-session" | "api-key";
export type RuntimeFeature =
| "instanceAdministration"
| "organizationAdministration"
| "ssoManagement"
| "remoteVolumeBackends";
| "remoteVolumeBackends"
| "apiKeys"
| "passwordAuthentication";
export const RUNTIME_FEATURES = {
server: {
@@ -13,12 +15,16 @@ export const RUNTIME_FEATURES = {
organizationAdministration: true,
ssoManagement: true,
remoteVolumeBackends: true,
apiKeys: true,
passwordAuthentication: true,
},
desktop: {
instanceAdministration: false,
organizationAdministration: false,
ssoManagement: false,
remoteVolumeBackends: false,
apiKeys: false,
passwordAuthentication: false,
},
} as const satisfies Record<Runtime, Record<RuntimeFeature, boolean>>;
@@ -65,7 +71,7 @@ const PERMISSIONS = {
},
"recoveryKey.download": {
orgRoles: ["owner", "admin"],
authSources: ["browser-session"],
authSources: ["browser-session", "desktop-session"],
},
} as const satisfies Record<string, PermissionPolicy>;

View File

@@ -5,6 +5,7 @@ import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
import { authService } from "~/server/modules/auth/auth.service";
import { isAuthRoute } from "~/lib/auth-routes";
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
import { invalidateAuthSession, isSessionAuthSourceAllowed } from "~/server/modules/auth/helpers";
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
const headers = getRequestHeaders();
@@ -21,6 +22,12 @@ export const authMiddleware = createMiddleware().server(async ({ next, request }
}
if (session?.user?.id) {
if (!isSessionAuthSourceAllowed(session.session.authSource)) {
await invalidateAuthSession(session.session.token);
throw redirect({ to: "/login" });
}
const hasSkippedRecoveryKeyDownload = getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id;
if (

View File

@@ -21,10 +21,12 @@ export const Route = createFileRoute("/(dashboard)/settings/")({
queryKey: ["organization-context"],
queryFn: () => getOrganizationContext(),
});
const userInvitationsPromise = context.queryClient.ensureQueryData({ ...getUserSsoInvitationsOptions() });
const [authContext, userInvitations] = await Promise.all([authContextPromise, userInvitationsPromise]);
await orgContextPromise;
const authContext = await authContextPromise;
const userInvitationsPromise = context.features.ssoManagement
? context.queryClient.ensureQueryData({ ...getUserSsoInvitationsOptions() })
: Promise.resolve([]);
const [userInvitations] = await Promise.all([userInvitationsPromise, orgContextPromise]);
const shouldPrefetchOrgQueries = context.permissions["organizationSettings.view"];

View File

@@ -22,6 +22,7 @@ import { logger } from "@zerobyte/core/node";
import { config } from "./core/config";
import { auth } from "~/server/lib/auth";
import { db } from "./db/db";
import { invalidateAuthSession, isSessionAuthSourceAllowed } from "./modules/auth/helpers";
const requestLogger = async (c: Context, next: Next) => {
const method = c.req.method;
@@ -98,7 +99,7 @@ export const createApp = () => {
.route("/api/v1/desktop", desktopController)
.route("/api/v1/events", eventsController);
app.on(["POST", "GET"], "/api/auth/*", (c) => {
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
const pathname = new URL(c.req.url).pathname;
if (pathname.startsWith("/api/auth/api-key/")) {
return c.json({ message: "API key management is only supported through API v1 routes" }, 404);
@@ -108,6 +109,13 @@ export const createApp = () => {
return c.json({ message: "API key authentication is only supported for API v1 routes" }, 401);
}
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (session && !isSessionAuthSourceAllowed(session.session.authSource)) {
await invalidateAuthSession(session.session.token, c);
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
}
return auth.handler(c.req.raw);
});
app.get("/api/v1/openapi.json", generalDescriptor(app));

View File

@@ -15,6 +15,8 @@ import type { NotificationConfig, NotificationType } from "~/schemas/notificatio
import type { ShortId } from "~/server/utils/branded";
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
type SessionAuthSource = "browser-session" | "desktop-session";
/**
* Users Table
*/
@@ -65,6 +67,7 @@ export const sessionsTable = sqliteTable(
userAgent: text("user_agent"),
impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"),
authSource: text("auth_source").$type<SessionAuthSource>().notNull().default("browser-session"),
},
(table) => [index("sessionsTable_userId_idx").on(table.userId)],
);

View File

@@ -155,6 +155,14 @@ export const auth = betterAuth({
},
session: {
modelName: "sessionsTable",
additionalFields: {
authSource: {
type: "string",
returned: true,
input: false,
defaultValue: "browser-session",
},
},
},
plugins: [
username({

View File

@@ -3,6 +3,7 @@ import { getRequestHeaders } from "@tanstack/react-start/server";
import type { Permission, RuntimeFeature } from "~/lib/permission-policy";
import { resolvePermissions } from "~/server/core/request-context";
import { auth } from "~/server/lib/auth";
import { getSessionAuthSource } from "~/server/modules/auth/helpers";
export const currentPermissionsQueryKey = ["current-permissions"] as const;
@@ -27,7 +28,7 @@ export const getCurrentPermissions = createServerFn({ method: "GET" }).handler(
const { permissions, features } = resolvePermissions({
instanceRole: session?.user?.role,
orgRole: activeMember?.role,
authSource: session?.user ? ("browser-session" as const) : null,
authSource: session?.user ? getSessionAuthSource(session.session.authSource) : null,
});
return { permissions, features };

View File

@@ -9,21 +9,29 @@ import {
type ListApiKeysDto,
} from "./api-keys.dto";
import { MAX_API_KEYS_PER_USER, countActiveApiKeys, hasApiKey, listApiKeys } from "./api-keys.service";
import { requireAuth, requireBrowserSession } from "../auth/auth.middleware";
import { requireAuth, requireBrowserSession, requireRuntimeFeature } from "../auth/auth.middleware";
import { auth } from "~/server/lib/auth";
import { isPasswordAuthSupported, userHasPassword, verifyUserPassword } from "../auth/helpers";
export const apiKeysController = new Hono()
.get("/api-keys", requireAuth, requireBrowserSession, getApiKeysDto, async (c) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const apiKeys = await listApiKeys(user.id, organizationId);
.get(
"/api-keys",
requireAuth,
requireRuntimeFeature("apiKeys"),
requireBrowserSession,
getApiKeysDto,
async (c) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const apiKeys = await listApiKeys(user.id, organizationId);
return c.json<ListApiKeysDto>({ apiKeys, limit: MAX_API_KEYS_PER_USER });
})
return c.json<ListApiKeysDto>({ apiKeys, limit: MAX_API_KEYS_PER_USER });
},
)
.post(
"/api-keys",
requireAuth,
requireRuntimeFeature("apiKeys"),
requireBrowserSession,
createApiKeyDto,
validator("json", createApiKeyBody),
@@ -69,20 +77,27 @@ export const apiKeysController = new Hono()
});
},
)
.delete("/api-keys/:keyId", requireAuth, requireBrowserSession, deleteApiKeyDto, async (c) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const keyId = c.req.param("keyId");
.delete(
"/api-keys/:keyId",
requireAuth,
requireRuntimeFeature("apiKeys"),
requireBrowserSession,
deleteApiKeyDto,
async (c) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const keyId = c.req.param("keyId");
const belongsToUserOrganization = await hasApiKey(user.id, organizationId, keyId);
if (!belongsToUserOrganization) {
return c.json({ message: "API key not found" }, 404);
}
const belongsToUserOrganization = await hasApiKey(user.id, organizationId, keyId);
if (!belongsToUserOrganization) {
return c.json({ message: "API key not found" }, 404);
}
await auth.api.deleteApiKey({
headers: c.req.raw.headers,
body: { keyId },
});
await auth.api.deleteApiKey({
headers: c.req.raw.headers,
body: { keyId },
});
return c.json({ success: true });
});
return c.json({ success: true });
},
);

View File

@@ -74,6 +74,16 @@ async function createStoredApiKey(session: TestSession, organizationId = session
});
}
async function createDesktopRuntimeSession() {
config.runtime = "desktop";
const session = await createTestSession();
await db
.update(sessionsTable)
.set({ authSource: "desktop-session" })
.where(eq(sessionsTable.token, session.session.token));
return session;
}
describe("API keys", () => {
test("creates and lists API keys for the current organization after password confirmation", async () => {
const session = await createTestSession();
@@ -165,7 +175,7 @@ describe("API keys", () => {
});
});
test("creates API keys without password confirmation when password auth is unsupported", async () => {
test("rejects browser sessions in desktop runtime", async () => {
config.runtime = "desktop";
const session = await createTestSession();
@@ -178,13 +188,21 @@ describe("API keys", () => {
body: JSON.stringify({ name: "Desktop key", password: "" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual(
expect.objectContaining({
name: "Desktop key",
key: expect.stringMatching(/^zb_/),
}),
);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({
message: "Invalid or expired session",
});
});
test("does not expose API key endpoints when the runtime feature is unavailable", async () => {
const session = await createDesktopRuntimeSession();
const res = await app.request("/api/v1/auth/api-keys", {
headers: session.headers,
});
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ message: "Not available in desktop mode" });
});
test("enforces the per-user API key limit", async () => {
@@ -323,6 +341,33 @@ describe("API keys", () => {
}
});
test("does not expose SSO invitation browser flow routes when the runtime feature is unavailable", async () => {
const session = await createDesktopRuntimeSession();
const routes = [
{ method: "GET", path: "/api/v1/auth/sso-invitations" },
{
method: "POST",
path: "/api/v1/auth/sso-invitations/test-invitation/verify",
body: { providerId: "test-provider" },
},
];
for (const route of routes) {
const res = await app.request(route.path, {
method: route.method,
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: route.body ? JSON.stringify(route.body) : undefined,
});
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ message: "Not available in desktop mode" });
}
});
test("does not allow API keys to mutate SSO admin resources", async () => {
const session = await createTestSessionWithOrgAdmin();
await addPassword(session);

View File

@@ -0,0 +1,21 @@
import { beforeEach, describe, expect, test } from "vitest";
import { config } from "~/server/core/config";
import { isPasswordAuthSupported, isSessionAuthSourceAllowed } from "../helpers";
describe("auth helpers", () => {
beforeEach(() => {
config.runtime = "server";
});
test("allows only the runtime session source and models password auth as a runtime feature", () => {
expect(isPasswordAuthSupported()).toBe(true);
expect(isSessionAuthSourceAllowed("browser-session")).toBe(true);
expect(isSessionAuthSourceAllowed("desktop-session")).toBe(false);
config.runtime = "desktop";
expect(isPasswordAuthSupported()).toBe(false);
expect(isSessionAuthSourceAllowed("browser-session")).toBe(false);
expect(isSessionAuthSourceAllowed("desktop-session")).toBe(true);
});
});

View File

@@ -1,12 +1,12 @@
import { createMiddleware } from "hono/factory";
import { auth } from "~/server/lib/auth";
import { db } from "~/server/db/db";
import { getPermission, withContext } from "~/server/core/request-context";
import { getPermission, hasFeature, withContext } from "~/server/core/request-context";
import { getApiKeyOrganizationId } from "../api-keys/api-keys.service";
import type { Permission } from "~/lib/permission-policy";
import type { AuthSource, Permission, RuntimeFeature } from "~/lib/permission-policy";
import { getSessionAuthSource, invalidateAuthSession, isSessionAuthSourceAllowed } from "./helpers";
const API_KEY_HEADER = "x-api-key";
type AuthSource = "browser-session" | "api-key";
type AuthenticatedUser = {
id: string;
email: string;
@@ -31,7 +31,7 @@ declare module "hono" {
*/
export const requireAuth = createMiddleware(async (c, next) => {
const apiKeyValue = c.req.header(API_KEY_HEADER);
const authSource: AuthSource = apiKeyValue ? "api-key" : "browser-session";
let authSource: AuthSource = apiKeyValue ? "api-key" : "browser-session";
let user: AuthenticatedUser | undefined;
let activeOrganizationId: string | null | undefined;
@@ -53,8 +53,15 @@ export const requireAuth = createMiddleware(async (c, next) => {
const sess = await auth.api.getSession({ headers: c.req.raw.headers });
if (sess) {
if (!isSessionAuthSourceAllowed(sess.session.authSource)) {
await invalidateAuthSession(sess.session.token, c);
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
}
user = sess.user;
activeOrganizationId = sess.session.activeOrganizationId;
authSource = getSessionAuthSource(sess.session.authSource);
}
}
@@ -100,6 +107,14 @@ export const requireAuth = createMiddleware(async (c, next) => {
});
export const requireBrowserSession = createMiddleware(async (c, next) => {
if (c.get("authSource") !== "browser-session") {
return c.json({ message: "Browser session required" }, 401);
}
await next();
});
export const requireUserSession = createMiddleware(async (c, next) => {
if (c.get("authSource") === "api-key") {
return c.json({ message: "Browser session required" }, 401);
}
@@ -107,6 +122,15 @@ export const requireBrowserSession = createMiddleware(async (c, next) => {
await next();
});
export const requireRuntimeFeature = (feature: RuntimeFeature) =>
createMiddleware(async (c, next) => {
if (!hasFeature(feature)) {
return c.json({ message: "Not available in desktop mode" }, 403);
}
await next();
});
export const requirePermission = (permission: Permission) =>
createMiddleware(async (c, next) => {
const result = getPermission(permission);
@@ -142,7 +166,7 @@ export const requireOrgAdmin = createMiddleware(async (c, next) => {
});
export const requireAdmin = createMiddleware(async (c, next) => {
if (c.get("authSource") === "api-key") {
if (c.get("authSource") !== "browser-session") {
return c.json({ message: "Browser session required" }, 401);
}

View File

@@ -1,15 +1,38 @@
import { eq } from "drizzle-orm";
import { verifyPassword } from "better-auth/crypto";
import type { Context } from "hono";
import { deleteCookie } from "hono/cookie";
import { hasRuntimeFeature } from "~/lib/permission-policy";
import { config } from "~/server/core/config";
import { db } from "~/server/db/db";
import { passkey, usersTable } from "~/server/db/schema";
import { auth } from "~/server/lib/auth";
type PasswordVerificationBody = {
userId: string;
password: string;
};
export const isPasswordAuthSupported = () => config.runtime !== "desktop";
type SessionAuthSource = "browser-session" | "desktop-session";
export const getSessionAuthSource = (authSource: string | null | undefined): SessionAuthSource =>
authSource === "desktop-session" ? "desktop-session" : "browser-session";
export const isSessionAuthSourceAllowed = (authSource: string | null | undefined) =>
getSessionAuthSource(authSource) === (config.runtime === "desktop" ? "desktop-session" : "browser-session");
export const invalidateAuthSession = async (token: string, c?: Context) => {
const authContext = await auth.$context;
await authContext.internalAdapter.deleteSession(token);
if (c) {
for (const cookie of Object.values(authContext.authCookies)) {
deleteCookie(c, cookie.name, cookie.attributes);
}
}
};
export const isPasswordAuthSupported = () => hasRuntimeFeature(config.runtime, "passwordAuthentication");
export const verifyUserPassword = async ({ password, userId }: PasswordVerificationBody) => {
const userAccount = await db.query.account.findFirst({

View File

@@ -1,11 +1,13 @@
import { afterEach, describe, expect, test } from "vitest";
import { eq } from "drizzle-orm";
import { hashPassword } from "better-auth/crypto";
import { createApp } from "~/server/app";
import { config } from "~/server/core/config";
import { db } from "~/server/db/db";
import { usersTable } from "~/server/db/schema";
import { account, usersTable } from "~/server/db/schema";
import { createTestSession } from "~/test/helpers/auth";
import { DESKTOP_LAUNCH_SECRET_HEADER } from "../desktop.service";
import { DESKTOP_USER_EMAIL } from "../bootstrap";
import { DESKTOP_USER_EMAIL } from "../constants";
const app = createApp();
const launchSecret = "s".repeat(32);
@@ -20,6 +22,32 @@ const useDesktopRuntime = () => {
config.desktop.launchSecret = launchSecret;
};
const createDesktopSessionCookie = async () => {
useDesktopRuntime();
const res = await app.request("/api/v1/desktop/session", {
method: "POST",
headers: {
[DESKTOP_LAUNCH_SECRET_HEADER]: launchSecret,
"Content-Type": "application/json",
},
body: JSON.stringify({ dateFormat: "DD/MM/YYYY", timeFormat: "24h" }),
});
const cookie = res.headers.get("set-cookie")?.split(";")[0];
const body = (await res.clone().json()) as { token: string };
expect(res.status).toBe(200);
expect(cookie).toBeTruthy();
return { cookie: cookie ?? "", token: body.token };
};
const expectSessionCookieCleared = (res: Response) => {
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toContain("zerobyte.session_token=");
expect(setCookie).toContain("Max-Age=0");
};
describe("desktopController", () => {
test("rejects desktop session requests without the launch secret", async () => {
useDesktopRuntime();
@@ -46,7 +74,7 @@ describe("desktopController", () => {
expect(res.status).toBe(400);
});
test("creates a normal session cookie when the launch secret is valid", async () => {
test("creates a desktop-scoped session cookie when the launch secret is valid", async () => {
useDesktopRuntime();
await db
.update(usersTable)
@@ -64,10 +92,112 @@ describe("desktopController", () => {
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toContain("zerobyte.session_token");
const body = (await res.clone().json()) as { token: string };
const desktopUser = await db.query.usersTable.findFirst({
where: { email: DESKTOP_USER_EMAIL },
});
const desktopAuthSession = await db.query.sessionsTable.findFirst({
where: { token: body.token },
});
expect(desktopUser?.hasDownloadedResticPassword).toBe(false);
expect(desktopAuthSession?.authSource).toBe("desktop-session");
});
test("rejects reserved desktop users that do not have the derived desktop credential", async () => {
useDesktopRuntime();
await db.delete(usersTable).where(eq(usersTable.email, DESKTOP_USER_EMAIL));
const userId = crypto.randomUUID();
try {
await db.insert(usersTable).values({
id: userId,
username: `desktop-collision-${crypto.randomUUID()}`,
name: "Desktop Collision",
email: DESKTOP_USER_EMAIL,
});
await db.insert(account).values({
id: crypto.randomUUID(),
accountId: DESKTOP_USER_EMAIL,
providerId: "credential",
userId,
password: await hashPassword("wrong-password"),
});
const res = await app.request("/api/v1/desktop/session", {
method: "POST",
headers: {
[DESKTOP_LAUNCH_SECRET_HEADER]: launchSecret,
"Content-Type": "application/json",
},
body: JSON.stringify({ dateFormat: "DD/MM/YYYY", timeFormat: "24h" }),
});
expect(res.status).toBe(401);
expect(await db.query.sessionsTable.findFirst({ where: { userId } })).toBeUndefined();
} finally {
await db.delete(usersTable).where(eq(usersTable.email, DESKTOP_USER_EMAIL));
}
});
test("does not treat desktop sessions as browser sessions for admin routes", async () => {
const v1Session = await createDesktopSessionCookie();
config.runtime = "server";
const adminRes = await app.request("/api/v1/auth/admin-users", {
headers: {
Cookie: v1Session.cookie,
},
});
expect(adminRes.status).toBe(401);
expectSessionCookieCleared(adminRes);
expect(await db.query.sessionsTable.findFirst({ where: { token: v1Session.token } })).toBeUndefined();
const directSession = await createDesktopSessionCookie();
config.runtime = "server";
const directSessionRes = await app.request("/api/auth/get-session", {
headers: {
Cookie: directSession.cookie,
},
});
expect(directSessionRes.status).toBe(401);
expectSessionCookieCleared(directSessionRes);
expect(await db.query.sessionsTable.findFirst({ where: { token: directSession.token } })).toBeUndefined();
const betterAuthAdminSession = await createDesktopSessionCookie();
config.runtime = "server";
const betterAuthAdminRes = await app.request("/api/auth/admin/list-users", {
headers: {
Cookie: betterAuthAdminSession.cookie,
},
});
expect(betterAuthAdminRes.status).toBe(401);
expectSessionCookieCleared(betterAuthAdminRes);
expect(
await db.query.sessionsTable.findFirst({ where: { token: betterAuthAdminSession.token } }),
).toBeUndefined();
});
test("does not allow browser sessions to self-mark as desktop sessions", async () => {
const session = await createTestSession();
const res = await app.request("/api/auth/update-session", {
method: "POST",
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ authSource: "desktop-session" }),
});
expect(res.status).toBe(400);
const storedSession = await db.query.sessionsTable.findFirst({
where: { token: session.session.token },
});
expect(storedSession?.authSource).toBe("browser-session");
});
});

View File

@@ -1,16 +1,14 @@
import { eq } from "drizzle-orm";
import { UnauthorizedError } from "http-errors-enhanced";
import type { DateFormatPreference, TimeFormatPreference } from "~/lib/datetime";
import { config } from "~/server/core/config";
import { db } from "~/server/db/db";
import { usersTable } from "~/server/db/schema";
import { verifyUserPassword } from "~/server/modules/auth/helpers";
import { ensureDefaultOrg } from "~/server/lib/auth/helpers/create-default-org";
import { auth } from "~/server/lib/auth";
import { cryptoUtils } from "~/server/utils/crypto";
export const DESKTOP_USER_EMAIL = "desktop@zerobyte.local";
export const DESKTOP_USERNAME = "desktop-admin";
export const getDesktopUserPassword = () => cryptoUtils.deriveSecret("zerobyte:desktop-user-password");
import { DESKTOP_USER_EMAIL, DESKTOP_USERNAME } from "./constants";
type DesktopDateTimePreferences = {
dateFormat: DateFormatPreference;
@@ -22,8 +20,8 @@ export const ensureDesktopIdentity = async ({ dateFormat, timeFormat }: DesktopD
return;
}
const password = await getDesktopUserPassword();
let user = await db.query.usersTable.findFirst({ where: { email: DESKTOP_USER_EMAIL } });
const password = await cryptoUtils.deriveSecret("zerobyte:desktop-user-password");
if (!user) {
await auth.api.signUpEmail({
@@ -40,6 +38,8 @@ export const ensureDesktopIdentity = async ({ dateFormat, timeFormat }: DesktopD
});
user = await db.query.usersTable.findFirst({ where: { email: DESKTOP_USER_EMAIL } });
} else if (!(await verifyUserPassword({ userId: user.id, password }))) {
throw new UnauthorizedError("Reserved desktop user is not trusted");
}
if (!user) {
@@ -52,4 +52,11 @@ export const ensureDesktopIdentity = async ({ dateFormat, timeFormat }: DesktopD
.update(usersTable)
.set({ role: "admin", emailVerified: true, updatedAt: new Date() })
.where(eq(usersTable.id, user.id));
const desktopUser = await db.query.usersTable.findFirst({ where: { id: user.id } });
if (!desktopUser) {
throw new Error("Failed to load desktop user");
}
return desktopUser;
};

View File

@@ -0,0 +1,2 @@
export const DESKTOP_USER_EMAIL = "desktop@zerobyte.local";
export const DESKTOP_USERNAME = "desktop-admin";

View File

@@ -1,6 +1,8 @@
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { setSignedCookie } from "hono/cookie";
import { validator } from "hono-openapi";
import { auth } from "~/server/lib/auth";
import { DESKTOP_LAUNCH_SECRET_HEADER, desktopService, verifyDesktopLaunchSecret } from "./desktop.service";
import { createDesktopSessionBody, createDesktopSessionDto } from "./desktop.dto";
@@ -15,6 +17,19 @@ export const desktopController = new Hono().post(
createDesktopSessionDto,
validator("json", createDesktopSessionBody),
async (c) => {
return desktopService.createDesktopSessionResponse(c.req.valid("json"));
const { session, user } = await desktopService.createDesktopSession(c.req.valid("json"));
const authContext = await auth.$context;
await setSignedCookie(c, authContext.authCookies.sessionToken.name, session.token, authContext.secret, {
...authContext.authCookies.sessionToken.attributes,
maxAge: authContext.sessionConfig.expiresIn,
});
return c.json({
redirect: false,
token: session.token,
url: undefined,
user,
});
},
);

View File

@@ -1,7 +1,7 @@
import { BadRequestError, UnauthorizedError } from "http-errors-enhanced";
import { config } from "~/server/core/config";
import { auth } from "~/server/lib/auth";
import { DESKTOP_USER_EMAIL, ensureDesktopIdentity, getDesktopUserPassword } from "~/server/modules/desktop/bootstrap";
import { ensureDesktopIdentity } from "~/server/modules/desktop/bootstrap";
import { cryptoUtils } from "~/server/utils/crypto";
import type { CreateDesktopSessionBody } from "./desktop.dto";
@@ -26,21 +26,23 @@ export const verifyDesktopLaunchSecret = (secret: string | undefined) => {
}
};
const createDesktopSessionResponse = async (body: CreateDesktopSessionBody) => {
const createDesktopSession = async (body: CreateDesktopSessionBody) => {
assertDesktopRuntime();
await ensureDesktopIdentity(body);
const password = await getDesktopUserPassword();
return auth.api.signInEmail({
body: {
email: DESKTOP_USER_EMAIL,
password,
rememberMe: true,
},
asResponse: true,
});
const user = await ensureDesktopIdentity(body);
if (!user) {
throw new Error("Failed to bootstrap desktop user");
}
const ctx = await auth.$context;
const session = await ctx.internalAdapter.createSession(user.id, false, { authSource: "desktop-session" }, true);
if (!session) {
throw new Error("Failed to create desktop session");
}
return { session, user };
};
export const desktopService = {
createDesktopSessionResponse,
createDesktopSession,
};

View File

@@ -3,12 +3,16 @@ import crypto from "node:crypto";
import { PassThrough } from "node:stream";
import { createApp } from "~/server/app";
import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema";
import { member, repositoriesTable, sessionsTable } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import type { RepositoryConfig } from "@zerobyte/core/restic";
import { restic } from "~/server/core/restic";
import { Effect } from "effect";
import { systemService } from "~/server/modules/system/system.service";
import { repositoriesService } from "../repositories.service";
import { eq } from "drizzle-orm";
import { config } from "~/server/core/config";
const app = createApp();
@@ -23,6 +27,7 @@ beforeEach(() => {
});
afterEach(() => {
config.runtime = "server";
vi.restoreAllMocks();
});
@@ -151,6 +156,31 @@ describe("repositories security", () => {
const body = await res.json();
expect(body.message).toBe("Invalid or expired session");
});
test("POST /api/v1/repositories/:shortId/exec should allow desktop sessions", async () => {
config.runtime = "desktop";
const desktopSession = await createTestSession();
await db.update(member).set({ role: "admin" }).where(eq(member.userId, desktopSession.user.id));
await db
.update(sessionsTable)
.set({ authSource: "desktop-session" })
.where(eq(sessionsTable.token, desktopSession.session.token));
vi.spyOn(systemService, "isDevPanelEnabled").mockReturnValue(true);
const execSpy = vi.spyOn(repositoriesService, "execResticCommand").mockResolvedValue({ exitCode: 0 });
const res = await app.request("/api/v1/repositories/test-repo/exec", {
method: "POST",
headers: {
...desktopSession.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ command: "version" }),
});
expect(res.status).toBe(200);
await res.text();
expect(execSpy).toHaveBeenCalled();
});
});
describe("information disclosure", () => {

View File

@@ -54,7 +54,7 @@ import {
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
import { requireAuth, requireBrowserSession, requireOrgAdmin } from "../auth/auth.middleware";
import { requireAuth, requireOrgAdmin, requireUserSession } from "../auth/auth.middleware";
import { toMessage } from "~/server/utils/errors";
import { requireDevPanel } from "../auth/dev-panel.middleware";
import { getSnapshotDuration } from "../../utils/snapshots";
@@ -291,7 +291,7 @@ export const repositoriesController = new Hono()
})
.post(
"/:shortId/exec",
requireBrowserSession,
requireUserSession,
requireDevPanel,
requireOrgAdmin,
devPanelExecDto,

View File

@@ -2,7 +2,16 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { eq } from "drizzle-orm";
import { createApp } from "~/server/app";
import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema";
import {
account,
invitation,
member,
organization,
sessionsTable,
ssoProvider,
usersTable,
verification,
} from "~/server/db/schema";
import { createTestSession, createTestSessionWithOrgAdmin } from "~/test/helpers/auth";
import { SSO_INVITATION_INTENT_COOKIE, ssoService } from "../sso.service";
import { config } from "~/server/core/config";
@@ -84,7 +93,11 @@ describe("SSO provider registration authorization", () => {
test("rejects provider registration when SSO management is unavailable", async () => {
config.runtime = "desktop";
const { headers, organizationId } = await createTestSession();
const { headers, organizationId, session } = await createTestSession();
await db
.update(sessionsTable)
.set({ authSource: "desktop-session" })
.where(eq(sessionsTable.token, session.token));
const response = await app.request(ssoRegisterUrl, {
method: "POST",

View File

@@ -45,7 +45,7 @@ export const authorizeSsoRegistration = async (ctx: AuthMiddlewareContext) => {
organizationId,
userId: session.user.id,
orgRole: membership.role,
authSource: "browser-session",
authSource: session.session.authSource,
},
() => getPermission("ssoProvider.create"),
);

View File

@@ -15,7 +15,7 @@ import {
updateSsoProviderAutoLinkingDto,
} from "./sso.dto";
import { SSO_INVITATION_INTENT_COOKIE, ssoService } from "./sso.service";
import { requireAuth, requireBrowserSession, requirePermission } from "../auth/auth.middleware";
import { requireAuth, requireBrowserSession, requirePermission, requireRuntimeFeature } from "../auth/auth.middleware";
import { auth } from "~/server/lib/auth";
import { mapAuthErrorToCode } from "./sso.errors";
import { config } from "~/server/core/config";
@@ -66,15 +66,23 @@ export const ssoController = new Hono()
})),
});
})
.get("/sso-invitations", requireAuth, requireBrowserSession, getUserSsoInvitationsDto, async (c) => {
const user = c.get("user");
const invitations = await ssoService.listPendingInvitationsForUser(user.email);
.get(
"/sso-invitations",
requireAuth,
requireRuntimeFeature("ssoManagement"),
requireBrowserSession,
getUserSsoInvitationsDto,
async (c) => {
const user = c.get("user");
const invitations = await ssoService.listPendingInvitationsForUser(user.email);
return c.json<UserSsoInvitationsDto>(invitations);
})
return c.json<UserSsoInvitationsDto>(invitations);
},
)
.post(
"/sso-invitations/:invitationId/verify",
requireAuth,
requireRuntimeFeature("ssoManagement"),
requireBrowserSession,
startInvitationSsoVerificationDto,
validator("json", startInvitationSsoVerificationBody),

View File

@@ -4,7 +4,7 @@ import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } f
import { systemService } from "../system.service";
import * as authHelpers from "~/server/modules/auth/helpers";
import { db } from "~/server/db/db";
import { organization, usersTable } from "~/server/db/schema";
import { organization, sessionsTable, usersTable } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { cryptoUtils } from "~/server/utils/crypto";
import { config } from "~/server/core/config";
@@ -14,6 +14,15 @@ const app = createApp();
let session: Awaited<ReturnType<typeof createTestSession>>;
let globalAdminSession: Awaited<ReturnType<typeof createTestSessionWithGlobalAdmin>>;
const createDesktopTestSession = async () => {
const desktopAuthSession = await createTestSession();
await db
.update(sessionsTable)
.set({ authSource: "desktop-session" })
.where(eq(sessionsTable.token, desktopAuthSession.session.token));
return desktopAuthSession;
};
beforeAll(async () => {
session = await createTestSession();
globalAdminSession = await createTestSessionWithGlobalAdmin();
@@ -51,10 +60,11 @@ describe("system security", () => {
test("returns desktop runtime and effective backend lists in desktop mode", async () => {
config.runtime = "desktop";
const desktopAuthSession = await createDesktopTestSession();
try {
const res = await app.request("/api/v1/system/info", {
headers: session.headers,
headers: desktopAuthSession.headers,
});
expect(res.status).toBe(200);
@@ -93,7 +103,9 @@ describe("system security", () => {
describe("registration-status endpoint", () => {
test("GET /api/v1/system/registration-status should be accessible with valid session", async () => {
const res = await app.request("/api/v1/system/registration-status", { headers: session.headers });
const res = await app.request("/api/v1/system/registration-status", {
headers: session.headers,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(typeof body.enabled).toBe("boolean");
@@ -185,8 +197,9 @@ describe("system security", () => {
expect(await res.text()).toBe(resticPassword);
});
test("should download restic password without password re-authentication in desktop mode", async () => {
test("should download restic password without password re-authentication for desktop sessions", async () => {
config.runtime = "desktop";
const desktopAuthSession = await createDesktopTestSession();
const { cryptoUtils: actualCryptoUtils } =
await vi.importActual<typeof import("~/server/utils/crypto")>("~/server/utils/crypto");
const resticPassword = "desktop-restic-password";
@@ -196,17 +209,17 @@ describe("system security", () => {
await db
.update(organization)
.set({ metadata: { resticPassword: encryptedResticPassword } })
.where(eq(organization.id, session.organizationId));
.where(eq(organization.id, desktopAuthSession.organizationId));
await db
.update(usersTable)
.set({ hasDownloadedResticPassword: false })
.where(eq(usersTable.id, session.user.id));
.where(eq(usersTable.id, desktopAuthSession.user.id));
vi.spyOn(cryptoUtils, "resolveSecret").mockImplementationOnce(actualCryptoUtils.resolveSecret);
const res = await app.request("/api/v1/system/restic-password", {
method: "POST",
headers: {
...session.headers,
...desktopAuthSession.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -218,10 +231,55 @@ describe("system security", () => {
expect(await res.text()).toBe(resticPassword);
expect(verifyPasswordSpy).not.toHaveBeenCalled();
const updatedUser = await db.query.usersTable.findFirst({ where: { id: session.user.id } });
const updatedUser = await db.query.usersTable.findFirst({
where: { id: desktopAuthSession.user.id },
});
expect(updatedUser?.hasDownloadedResticPassword).toBe(true);
});
test("rejects browser sessions in desktop mode", async () => {
config.runtime = "desktop";
const browserSession = await createTestSession();
const verifyPasswordSpy = vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(false);
const res = await app.request("/api/v1/system/restic-password", {
method: "POST",
headers: {
...browserSession.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
password: "wrong-password",
}),
});
expect(res.status).toBe(401);
expect(verifyPasswordSpy).not.toHaveBeenCalled();
const body = await res.json();
expect(body.message).toBe("Invalid or expired session");
});
test("rejects desktop sessions outside desktop mode", async () => {
const desktopAuthSession = await createDesktopTestSession();
const verifyPasswordSpy = vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(false);
const res = await app.request("/api/v1/system/restic-password", {
method: "POST",
headers: {
...desktopAuthSession.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
password: "wrong-password",
}),
});
expect(res.status).toBe(401);
expect(verifyPasswordSpy).not.toHaveBeenCalled();
const body = await res.json();
expect(body.message).toBe("Invalid or expired session");
});
test("should return 400 for invalid payload on restic-password", async () => {
const res = await app.request("/api/v1/system/restic-password", {
method: "POST",

View File

@@ -19,7 +19,7 @@ import { requireAuth, requirePermission } from "../auth/auth.middleware";
import { db } from "../../db/db";
import { usersTable } from "../../db/schema";
import { eq } from "drizzle-orm";
import { isPasswordAuthSupported, userHasPassword, verifyUserPassword } from "../auth/helpers";
import { userHasPassword, verifyUserPassword } from "../auth/helpers";
import { cryptoUtils } from "../../utils/crypto";
import { getOrganizationId } from "~/server/core/request-context";
@@ -63,20 +63,25 @@ export const systemController = new Hono()
const user = c.get("user");
const organizationId = getOrganizationId();
const body = c.req.valid("json");
if (isPasswordAuthSupported()) {
if (c.get("authSource") !== "desktop-session") {
const hasPassword = await userHasPassword(user.id);
if (!hasPassword) {
return c.json({ message: "A local password is required to download the recovery key" }, 403);
}
const isPasswordValid = await verifyUserPassword({ password: body.password, userId: user.id });
const isPasswordValid = await verifyUserPassword({
password: body.password,
userId: user.id,
});
if (!isPasswordValid) {
return c.json({ message: "Invalid password" }, 401);
}
}
try {
const org = await db.query.organization.findFirst({ where: { id: organizationId } });
const org = await db.query.organization.findFirst({
where: { id: organizationId },
});
if (!org?.metadata?.resticPassword) {
return c.json({ message: "Organization Restic password not found" }, 404);