Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions apps/web/content/docs/dev/advanced/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const vitNodeApiConfig = buildApiConfig({
// [!code ++:6]
authorization: {
cookieExpires: 1000 * 60 * 60 * 24 * 30, // 30 days for public users
adminCookieExpires: 1000 * 60 * 60 * 8, // 8 hours for AdminCP
adminCookieExpires: 1000 * 60 * 30, // sign staff out after 30 idle minutes
cookieDomain: ".yourdomain.com", // Optional cross-subdomain sharing
},
})
Expand All @@ -32,7 +32,7 @@ export const vitNodeApiConfig = buildApiConfig({
| Session Type | Cookie Name | Default Lifetime | Storage Table | Purpose |
| :--- | :--- | :--- | :--- | :--- |
| **Public Session** | `vitnode_auth` | 90 days | `core_sessions` | Frontend member authentication |
| **Admin Session** | `vitnode_auth_admin` | 1 day | `core_admin_sessions` | High-privilege AdminCP access |
| **Admin Session** | `vitnode_auth_admin` | 1 hour of inactivity | `core_admin_sessions` | High-privilege AdminCP access |
| **Known Device** | `vitnode_device` | 1 year | `core_sessions_known_devices` | Device authorization tracking |

<Callout type="info" title="Session Isolation">
Expand All @@ -41,6 +41,20 @@ export const vitNodeApiConfig = buildApiConfig({

---

## AdminCP Session Timeout

The AdminCP is the keys to the kingdom, so its session is deliberately short-lived. Staff are asked to sign in again when:

- **They go quiet for an hour.** Every AdminCP request pushes the expiry an hour ahead, so an admin who keeps working is never interrupted. Leave the tab alone for an hour and it signs itself out and lands on the sign-in page, with a toast explaining why. Tune the window with `adminCookieExpires`.
- **They close every AdminCP tab.** Open AdminCP tabs keep a heartbeat going. Open the AdminCP again after every tab was closed and VitNode ends the old session instead of letting it back in. A page reload, or opening one more AdminCP tab while another is still open, is not affected. This check is skipped when `cookieDomain` is set: a browser can only see tabs on its own origin, so an AdminCP tab open on another subdomain would look closed.
- **They close the browser.** The `vitnode_auth_admin` cookie has no expiry date, so the browser drops it when it shuts down.

<Callout type="info" title="Leaving the AdminCP counts as closing it">
Moving from the AdminCP to the public site in your only AdminCP tab stops the heartbeat. Come back within about 15 seconds and you are still signed in; any later and you sign in again. Open the public site in a new tab to keep your AdminCP session.
</Callout>

---

## Security Guarantees

- **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash.
Expand Down Expand Up @@ -100,8 +114,8 @@ The rules are per-provider, because they are only true per-provider:
type: "number",
},
adminCookieExpires: {
default: "1 day",
description: "AdminCP session lifetime in milliseconds.",
default: "1 hour",
description: "How long an AdminCP session survives without any AdminCP activity, in milliseconds. Each request extends it.",
type: "number",
},
cookieSecure: {
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/locales/@vitnode/core/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,16 @@
"hint": "Wpisz co najmniej {count} znaki, aby wyszukać użytkowników.",
"placeholder": "Szukaj stron i użytkowników...",
"title": "Wyszukiwanie w panelu"
},
"session": {
"expired": {
"title": "Sesja panelu administracyjnego wygasła",
"desc": "Wylogowaliśmy Cię z powodu braku aktywności. Zaloguj się ponownie, aby kontynuować."
},
"tabs_closed": {
"title": "Zaloguj się ponownie",
"desc": "Wszystkie karty panelu administracyjnego zostały zamknięte, więc dla bezpieczeństwa wylogowaliśmy Cię."
}
}
},
"navigation": {
Expand Down
14 changes: 9 additions & 5 deletions packages/vitnode/src/api/middlewares/global.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface EnvVariablesVitNode {
showRealName: boolean;
};
};
adminSessionExpiresAt: Date | null;
ai: AIModel;
cache: CacheModel;
core: {
Expand Down Expand Up @@ -430,8 +431,7 @@ export const globalMiddleware = ({
deviceCookieExpires:
authorization?.deviceCookieExpires ?? 1000 * 60 * 60 * 24 * 365, // 1 year,
adminCookieName: authorization?.adminCookieName ?? "vitnode_auth_admin",
adminCookieExpires:
authorization?.adminCookieExpires ?? 1000 * 60 * 60 * 24 * 1, // 1 day
adminCookieExpires: authorization?.adminCookieExpires ?? 1000 * 60 * 60,
cookieSecure: authorization?.cookieSecure ?? true,
// No default on purpose: absent means host-only, which is correct on
// localhost, on a generated preview hostname and in production alike.
Expand Down Expand Up @@ -471,6 +471,7 @@ export const globalMiddleware = ({
const user = await new SessionModel(c).getUser();
c.set("user", user);
c.set("admin", null);
c.set("adminSessionExpiresAt", null);
c.set("log", loggerMiddleware(c));

await next();
Expand All @@ -479,11 +480,14 @@ export const globalMiddleware = ({

export const globalAdminMiddleware = () => {
return async (c: Context, next: Next) => {
const user = await new SessionAdminModel(c).getUser();
if (!user) throw new HTTPException(403);
const session = await new SessionAdminModel(c).getSession({
extend: c.req.query("passive") !== "true",
});
if (!session) throw new HTTPException(403);
c.set("admin", {
user,
user: session.user,
});
c.set("adminSessionExpiresAt", session.expiresAt);

await next();
};
Expand Down
20 changes: 14 additions & 6 deletions packages/vitnode/src/api/models/passkey-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Context } from "hono";

import { and, asc, count, eq, gt, isNull, lte, ne } from "drizzle-orm";
import { and, asc, count, eq, gt, inArray, isNull, lte, ne } from "drizzle-orm";

import {
core_users_passkey_challenges,
Expand Down Expand Up @@ -63,6 +63,7 @@ export interface PasskeyStore {
deletePasskey: (args: {
canDelete: (facts: PasskeyRecoveryFacts) => boolean;
id: number;
ssoProviderIds: string[];
userId: number;
}) => Promise<DeletePasskeyOutcome>;
findPasskeyByCredentialId: (
Expand Down Expand Up @@ -140,7 +141,7 @@ export const drizzlePasskeyStore = (db: Db): PasskeyStore => ({
.where(lte(core_users_passkey_challenges.expiresAt, now));
},

deletePasskey: async ({ canDelete, id, userId }) =>
deletePasskey: async ({ canDelete, id, ssoProviderIds, userId }) =>
await db.transaction(async tx => {
const [user] = await tx
.select({ password: core_users.password })
Expand Down Expand Up @@ -170,10 +171,17 @@ export const drizzlePasskeyStore = (db: Db): PasskeyStore => ({
ne(core_users_passkeys.id, id),
),
),
tx
.select({ value: count() })
.from(core_users_sso)
.where(eq(core_users_sso.userId, userId)),
ssoProviderIds.length > 0
? tx
.select({ value: count() })
.from(core_users_sso)
.where(
and(
eq(core_users_sso.userId, userId),
inArray(core_users_sso.providerId, ssoProviderIds),
),
)
: [{ value: 0 }],
]);

const allowed = canDelete({
Expand Down
7 changes: 5 additions & 2 deletions packages/vitnode/src/api/models/passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ export class PasskeyModel {
const admin = new SessionAdminModel(this.c);
if (!(await admin.checkIfUserIsAdmin(userId))) return;

const adminUser = await admin.getUser();
if (adminUser?.id !== userId) {
const adminSession = await admin.getSession({ extend: true });
if (adminSession?.user.id !== userId) {
throw new PasskeyError("admin_session_required", 403);
}
}
Expand Down Expand Up @@ -226,6 +226,9 @@ export class PasskeyModel {
hasPassword: passwordEnabled && facts.hasPassword,
}),
id,
ssoProviderIds: this.c
.get("core")
.authorization.ssoAdapters.map(adapter => adapter.id),
userId,
});

Expand Down
220 changes: 220 additions & 0 deletions packages/vitnode/src/api/models/session-admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// @vitest-environment node
import { Hono } from "hono";
import { describe, expect, it } from "vitest";

import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware";

import { core_admin_permissions, core_admin_sessions } from "@/database/admins";
import { core_sessions_known_devices } from "@/database/sessions";
import { core_users } from "@/database/users";

import type { AdminSession } from "./session-cache";

import { SessionAdminModel } from "./session-admin";

const IDLE_TIMEOUT_MS = 1000 * 60 * 60;

const AUTHORIZATION = {
adminCookieExpires: IDLE_TIMEOUT_MS,
adminCookieName: "vitnode_auth_admin",
cookieDomain: undefined,
cookie_expires: 1000 * 60 * 60 * 24 * 90,
cookieName: "vitnode_auth",
cookieSecure: true,
deviceCookieExpires: 1000 * 60 * 60 * 24 * 365,
deviceCookieName: "vitnode_device",
passkeys: { enabled: false, problems: [] },
password: { enabled: true },
ssoAdapters: [],
} satisfies EnvVariablesVitNode["core"]["authorization"];

interface AdminSessionUpdate {
expiresAt: Date;
lastSeen: Date;
}

const fakeDb = ({ sessionExpiresAt }: { sessionExpiresAt: Date | null }) => {
const adminSessionUpdates: AdminSessionUpdate[] = [];

const chain = (kind: string, table: unknown) => {
const op: { kind: string; table: unknown; values?: unknown } = {
kind,
table,
};
const rows = (): unknown[] => {
if (op.kind === "update" && op.table === core_admin_sessions) {
adminSessionUpdates.push(op.values as AdminSessionUpdate);

return [];
}
if (op.kind !== "select") return [];
if (op.table === core_sessions_known_devices) return [{ id: 3 }];
if (op.table === core_admin_permissions) return [{ id: 1 }];
if (op.table === core_admin_sessions) {
return sessionExpiresAt
? [{ expiresAt: sessionExpiresAt, userId: 7 }]
: [];
}
if (op.table === core_users) {
return [
{ avatarKey: null, coverKey: null, id: 7, name: "Test", roleId: 1 },
];
}

return [];
};

const self = {
from: (from: unknown) => {
op.table = from;

return self;
},
leftJoin: () => self,
limit: () => self,
set: (values: unknown) => {
op.values = values;

return self;
},
then: async (onFulfilled: (value: unknown[]) => unknown) =>
await Promise.resolve(onFulfilled(rows())),
where: () => self,
};

return self;
};

return {
adminSessionUpdates,
db: {
select: () => chain("select", undefined),
update: (table: unknown) => chain("update", table),
},
};
};

const fakeCache = () => {
const entries = new Map<string, unknown>();

return {
entries,
cache: {
deleteSystem: async (key: string) => {
entries.delete(key);
await Promise.resolve();
},
getSystem: async <T>(key: string) =>
await Promise.resolve((entries.get(key) ?? null) as null | T),
setSystem: async (key: string, value: unknown) => {
entries.set(key, JSON.parse(JSON.stringify(value)));
await Promise.resolve();
},
},
};
};

const readSession = async ({
cache = fakeCache(),
extend,
sessionExpiresAt,
}: {
cache?: ReturnType<typeof fakeCache>;
extend: boolean;
sessionExpiresAt: Date | null;
}) => {
const { adminSessionUpdates, db } = fakeDb({ sessionExpiresAt });
let session: AdminSession | null = null;
const app = new Hono();

app.all("*", async c => {
c.set("core", {
authorization: AUTHORIZATION,
} as unknown as EnvVariablesVitNode["core"]);
c.set("db", db as unknown as EnvVariablesVitNode["db"]);
c.set("cache", cache.cache as unknown as EnvVariablesVitNode["cache"]);
c.set("ipAddress", "203.0.113.7");

session = await new SessionAdminModel(c).getSession({ extend });

return c.body(null, 204);
});

const response = await app.request("https://vitnode.com/api/x", {
headers: { cookie: "vitnode_auth_admin=token; vitnode_device=device" },
});

return {
adminSessionUpdates,
session: session as AdminSession | null,
setCookies: response.headers.getSetCookie(),
};
};

const fromNow = (ms: number) => new Date(Date.now() + ms);

describe("admin session idle timeout", () => {
it("pushes the expiry a full idle timeout ahead on activity", async () => {
const { adminSessionUpdates, session } = await readSession({
extend: true,
sessionExpiresAt: fromNow(10 * 60_000),
});

expect(adminSessionUpdates).toHaveLength(1);
const [update] = adminSessionUpdates;
expect(update?.expiresAt.getTime()).toBeGreaterThan(
Date.now() + IDLE_TIMEOUT_MS - 5_000,
);
expect(session?.expiresAt).toEqual(update?.expiresAt);
expect(session?.user.id).toBe(7);
});

it("leaves the expiry alone on a passive read", async () => {
const expiresAt = fromNow(10 * 60_000);
const { adminSessionUpdates, session } = await readSession({
extend: false,
sessionExpiresAt: expiresAt,
});

expect(adminSessionUpdates).toHaveLength(0);
expect(session?.expiresAt).toEqual(expiresAt);
});

it("does not write on every request right after an extension", async () => {
const { adminSessionUpdates } = await readSession({
extend: true,
sessionExpiresAt: fromNow(IDLE_TIMEOUT_MS - 5_000),
});

expect(adminSessionUpdates).toHaveLength(0);
});

it("serves the extended expiry from the cache on the next request", async () => {
const cache = fakeCache();
const first = await readSession({
cache,
extend: true,
sessionExpiresAt: fromNow(10 * 60_000),
});
const second = await readSession({
cache,
extend: false,
sessionExpiresAt: null,
});

expect(second.session?.expiresAt).toEqual(first.session?.expiresAt);
expect(second.session?.user.id).toBe(7);
});

it("signs out an idle session and clears its cookie", async () => {
const { session, setCookies } = await readSession({
extend: true,
sessionExpiresAt: null,
});

expect(session).toBeNull();
expect(
setCookies.some(cookie => cookie.startsWith("vitnode_auth_admin=;")),
).toBe(true);
});
});
Loading
Loading