mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-06-16 04:22:34 -04:00
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:
@@ -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 = () => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
1
app/drizzle/20260615163731_empty_vulcan/migration.sql
Normal file
1
app/drizzle/20260615163731_empty_vulcan/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `sessions_table` ADD `auth_source` text DEFAULT 'browser-session' NOT NULL;
|
||||
3509
app/drizzle/20260615163731_empty_vulcan/snapshot.json
Normal file
3509
app/drizzle/20260615163731_empty_vulcan/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
|
||||
@@ -155,6 +155,14 @@ export const auth = betterAuth({
|
||||
},
|
||||
session: {
|
||||
modelName: "sessionsTable",
|
||||
additionalFields: {
|
||||
authSource: {
|
||||
type: "string",
|
||||
returned: true,
|
||||
input: false,
|
||||
defaultValue: "browser-session",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
username({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
21
app/server/modules/auth/__tests__/auth.helpers.test.ts
Normal file
21
app/server/modules/auth/__tests__/auth.helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
2
app/server/modules/desktop/constants.ts
Normal file
2
app/server/modules/desktop/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const DESKTOP_USER_EMAIL = "desktop@zerobyte.local";
|
||||
export const DESKTOP_USERNAME = "desktop-admin";
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user