diff --git a/apps/web/content/docs/dev/advanced/auth.mdx b/apps/web/content/docs/dev/advanced/auth.mdx index f72c8b869..4c4ff1580 100644 --- a/apps/web/content/docs/dev/advanced/auth.mdx +++ b/apps/web/content/docs/dev/advanced/auth.mdx @@ -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 }, }) @@ -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 | @@ -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. + + + 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. + + +--- + ## 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. @@ -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: { diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index a931a929a..b9cb2b673 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -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": { diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 25984651a..cbc40ceb9 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -100,6 +100,7 @@ export interface EnvVariablesVitNode { showRealName: boolean; }; }; + adminSessionExpiresAt: Date | null; ai: AIModel; cache: CacheModel; core: { @@ -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. @@ -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(); @@ -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(); }; diff --git a/packages/vitnode/src/api/models/passkey-store.ts b/packages/vitnode/src/api/models/passkey-store.ts index 15247b524..d95540aee 100644 --- a/packages/vitnode/src/api/models/passkey-store.ts +++ b/packages/vitnode/src/api/models/passkey-store.ts @@ -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, @@ -63,6 +63,7 @@ export interface PasskeyStore { deletePasskey: (args: { canDelete: (facts: PasskeyRecoveryFacts) => boolean; id: number; + ssoProviderIds: string[]; userId: number; }) => Promise; findPasskeyByCredentialId: ( @@ -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 }) @@ -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({ diff --git a/packages/vitnode/src/api/models/passkey.ts b/packages/vitnode/src/api/models/passkey.ts index 1a69a4b2c..4231bb37f 100644 --- a/packages/vitnode/src/api/models/passkey.ts +++ b/packages/vitnode/src/api/models/passkey.ts @@ -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); } } @@ -226,6 +226,9 @@ export class PasskeyModel { hasPassword: passwordEnabled && facts.hasPassword, }), id, + ssoProviderIds: this.c + .get("core") + .authorization.ssoAdapters.map(adapter => adapter.id), userId, }); diff --git a/packages/vitnode/src/api/models/session-admin.test.ts b/packages/vitnode/src/api/models/session-admin.test.ts new file mode 100644 index 000000000..83f508c68 --- /dev/null +++ b/packages/vitnode/src/api/models/session-admin.test.ts @@ -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(); + + return { + entries, + cache: { + deleteSystem: async (key: string) => { + entries.delete(key); + await Promise.resolve(); + }, + getSystem: async (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; + 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); + }); +}); diff --git a/packages/vitnode/src/api/models/session-admin.ts b/packages/vitnode/src/api/models/session-admin.ts index 792bc254b..651274fa4 100644 --- a/packages/vitnode/src/api/models/session-admin.ts +++ b/packages/vitnode/src/api/models/session-admin.ts @@ -10,8 +10,10 @@ import { core_admin_permissions, core_admin_sessions } from "@/database/admins"; import { DeviceModel } from "./device"; import { + type AdminSession, adminSessionCacheKey, - reviveSessionUser, + isAdminSessionExtensionDue, + reviveAdminSession, sessionCacheTtl, type SessionUser, } from "./session-cache"; @@ -23,6 +25,64 @@ export class SessionAdminModel { } protected readonly c: Context; + private async extendSession({ + deviceId, + hashedToken, + user, + }: { + deviceId: number; + hashedToken: string; + user: SessionUser; + }): Promise { + const now = new Date(); + const expiresAt = new Date( + now.getTime() + this.c.get("core").authorization.adminCookieExpires, + ); + + await this.c + .get("db") + .update(core_admin_sessions) + .set({ expiresAt, lastSeen: now }) + .where( + and( + eq(core_admin_sessions.token, hashedToken), + eq(core_admin_sessions.deviceId, deviceId), + ), + ); + + return { expiresAt, user }; + } + + private async findActiveSession( + hashedToken: string, + deviceId: number, + ): Promise { + const [session] = await this.c + .get("db") + .select({ + userId: core_admin_sessions.userId, + expiresAt: core_admin_sessions.expiresAt, + }) + .from(core_admin_sessions) + .where( + and( + eq(core_admin_sessions.token, hashedToken), + eq(core_admin_sessions.deviceId, deviceId), + gt(core_admin_sessions.expiresAt, new Date()), + ), + ) + .limit(1); + + if (!session) return null; + + const user = await new UserModel().getUserById({ + id: session.userId, + c: this.c, + }); + + return user ? { expiresAt: session.expiresAt, user } : null; + } + async checkIfUserIsAdmin(userId: number) { const user = await new UserModel().getUserById({ id: userId, c: this.c }); if (!user) return false; @@ -75,11 +135,6 @@ export class SessionAdminModel { this.c, this.c.get("core").authorization.adminCookieName, token, - { - expires: new Date( - Date.now() + this.c.get("core").authorization.adminCookieExpires, - ), - }, ); return { token }; @@ -112,7 +167,11 @@ export class SessionAdminModel { deleteAuthCookie(this.c, this.c.get("core").authorization.adminCookieName); } - async getUser() { + async getSession({ + extend, + }: { + extend: boolean; + }): Promise { const { authorization } = this.c.get("core"); const token = getCookie(this.c, authorization.adminCookieName); if (!token) return null; @@ -127,61 +186,43 @@ export class SessionAdminModel { // Fast path: skip the session + user lookups for a session resolved on a // recent request. Admin status is still re-checked live below so a revoked // admin loses access immediately, not when the cache expires. - const cached = await cache.getSystem(cacheKey); - if (cached) { - const user = reviveSessionUser(cached); - if (!(await this.checkIfUserIsAdmin(user.id))) { - await this.deleteSession(); - - return null; - } - - return user; - } - - const [session] = await this.c - .get("db") - .select({ - userId: core_admin_sessions.userId, - expiresAt: core_admin_sessions.expiresAt, - }) - .from(core_admin_sessions) - .where( - and( - eq(core_admin_sessions.token, hashedToken), - eq(core_admin_sessions.deviceId, device.id), - gt(core_admin_sessions.expiresAt, new Date()), - ), - ) - .limit(1); + const cached = await cache.getSystem(cacheKey); + const session = cached + ? reviveAdminSession(cached) + : await this.findActiveSession(hashedToken, device.id); if (!session) { - deleteAuthCookie( - this.c, - this.c.get("core").authorization.adminCookieName, - ); + deleteAuthCookie(this.c, authorization.adminCookieName); return null; } - const user = await new UserModel().getUserById({ - id: session.userId, - c: this.c, - }); - - if (!user) return null; - const isStillAdmin = await this.checkIfUserIsAdmin(user.id); - if (!isStillAdmin) { + if (!(await this.checkIfUserIsAdmin(session.user.id))) { await this.deleteSession(); return null; } + const current = + extend && + isAdminSessionExtensionDue({ + expiresAt: session.expiresAt, + idleTimeoutMs: authorization.adminCookieExpires, + }) + ? await this.extendSession({ + deviceId: device.id, + hashedToken, + user: session.user, + }) + : session; + + if (cached && current === session) return current; + // Cap the TTL to the session's remaining lifetime so an expired session is // never served from cache. - const ttl = sessionCacheTtl(session.expiresAt); - if (ttl > 0) await cache.setSystem(cacheKey, user, ttl); + const ttl = sessionCacheTtl(current.expiresAt); + if (ttl > 0) await cache.setSystem(cacheKey, current, ttl); - return user; + return current; } } diff --git a/packages/vitnode/src/api/models/session-cache.test.ts b/packages/vitnode/src/api/models/session-cache.test.ts index ad98273bb..2d08958e3 100644 --- a/packages/vitnode/src/api/models/session-cache.test.ts +++ b/packages/vitnode/src/api/models/session-cache.test.ts @@ -8,6 +8,7 @@ import type { SessionUser } from "./session-cache"; import { adminSessionCacheKey, + isAdminSessionExtensionDue, reviveSessionUser, SESSION_CACHE_TTL_SECONDS, sessionCacheKey, @@ -171,3 +172,26 @@ describe("a user read back from the cache is the user that was written", () => { }); }); }); + +describe("an active admin session is always renewable", () => { + const now = 1_000_000; + const due = (idleTimeoutMs: number, elapsedMs: number) => + isAdminSessionExtensionDue({ + expiresAt: new Date(now + idleTimeoutMs - elapsedMs), + idleTimeoutMs, + now, + }); + + it("waits a minute between extensions of the default hour", () => { + expect(due(60 * 60_000, 30_000)).toBe(false); + expect(due(60 * 60_000, 61_000)).toBe(true); + }); + + it.each([60_000, 30_000, 5_000])( + "extends a %ims idle timeout before it runs out", + idleTimeoutMs => { + expect(due(idleTimeoutMs, 0)).toBe(false); + expect(due(idleTimeoutMs, idleTimeoutMs - 1)).toBe(true); + }, + ); +}); diff --git a/packages/vitnode/src/api/models/session-cache.ts b/packages/vitnode/src/api/models/session-cache.ts index ef51aa3d4..039cbbf79 100644 --- a/packages/vitnode/src/api/models/session-cache.ts +++ b/packages/vitnode/src/api/models/session-cache.ts @@ -12,7 +12,7 @@ export const sessionCacheKey = ( export const adminSessionCacheKey = ( hashedToken: string, deviceId: number, -): string => `session:admin:${deviceId}:${hashedToken}`; +): string => `session:admin-idle:${deviceId}:${hashedToken}`; export const sessionCacheTtl = (expiresAt: Date): number => Math.min( @@ -25,3 +25,30 @@ export const reviveSessionUser = (user: SessionUser): SessionUser => ({ createdAt: new Date(user.createdAt), birthday: user.birthday ? new Date(user.birthday) : null, }); + +export interface AdminSession { + expiresAt: Date; + user: SessionUser; +} + +export const reviveAdminSession = (session: AdminSession): AdminSession => ({ + expiresAt: new Date(session.expiresAt), + user: reviveSessionUser(session.user), +}); + +export const ADMIN_SESSION_MAX_EXTEND_INTERVAL_MS = 60_000; + +export const adminSessionExtendInterval = (idleTimeoutMs: number): number => + Math.min(ADMIN_SESSION_MAX_EXTEND_INTERVAL_MS, idleTimeoutMs / 2); + +export const isAdminSessionExtensionDue = ({ + expiresAt, + idleTimeoutMs, + now = Date.now(), +}: { + expiresAt: Date; + idleTimeoutMs: number; + now?: number; +}): boolean => + idleTimeoutMs - (expiresAt.getTime() - now) > + adminSessionExtendInterval(idleTimeoutMs); diff --git a/packages/vitnode/src/api/models/session-cookies.test.ts b/packages/vitnode/src/api/models/session-cookies.test.ts index 0aa14ee19..0a6e0e0bb 100644 --- a/packages/vitnode/src/api/models/session-cookies.test.ts +++ b/packages/vitnode/src/api/models/session-cookies.test.ts @@ -205,6 +205,16 @@ describe("admin session cookie", () => { const remove = async (c: Context) => await new SessionAdminModel(c).deleteSession(); + it("ends with the browser session instead of carrying an expiry", async () => { + const cookie = named( + await setCookiesFrom({ act: create }), + AUTHORIZATION.adminCookieName, + ); + + expect(cookie.options.expires).toBe(undefined); + expect(cookie.options.maxAge).toBe(undefined); + }); + it.each(HOSTS)("is host-only on %s", async (_label, url) => { const cookie = named( await setCookiesFrom({ act: create, url }), diff --git a/packages/vitnode/src/api/modules/admin/routes/session.route.test.ts b/packages/vitnode/src/api/modules/admin/routes/session.route.test.ts new file mode 100644 index 000000000..0b40bca61 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/routes/session.route.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import { createTestCache } from "@/tests/cache"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +import { sessionAdminRoute } from "./session.route"; + +const EXPIRES_AT = new Date("2026-09-29T12:00:00.000Z"); + +const readSession = async ({ + cookieDomain, +}: { + cookieDomain: string | undefined; +}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: 7, + }); + const app = new OpenAPIHono(); + + app.use("*", async (c, next) => { + c.set("admin", { + user: { id: 7, roleId: 1 }, + } as unknown as Context["var"]["admin"]); + c.set("adminSessionExpiresAt", EXPIRES_AT); + c.set("cache", cache); + c.set("core", { + authorization: { cookieDomain }, + } as unknown as Context["var"]["core"]); + await next(); + }); + app.openapi(sessionAdminRoute.route, sessionAdminRoute.handler); + + const response = await app.request("/session"); + + return { + body: (await response.json()) as { + expiresAt: string; + signOutWhenTabsClose: boolean; + }, + status: response.status, + }; +}; + +describe("admin session", () => { + it("reports when it expires", async () => { + const { body, status } = await readSession({ cookieDomain: undefined }); + + expect(status).toBe(200); + expect(body.expiresAt).toBe(EXPIRES_AT.toISOString()); + }); + + it("ends with the last AdminCP tab when its cookie is host-only", async () => { + const { body } = await readSession({ cookieDomain: undefined }); + + expect(body.signOutWhenTabsClose).toBe(true); + }); + + it("outlives closed tabs when its cookie is shared across subdomains", async () => { + const { body } = await readSession({ cookieDomain: ".example.com" }); + + expect(body.signOutWhenTabsClose).toBe(false); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/routes/session.route.ts b/packages/vitnode/src/api/modules/admin/routes/session.route.ts index 980ee37d0..4409a36d7 100644 --- a/packages/vitnode/src/api/modules/admin/routes/session.route.ts +++ b/packages/vitnode/src/api/modules/admin/routes/session.route.ts @@ -11,6 +11,11 @@ export const sessionAdminRoute = buildRoute({ method: "get", description: "Verify admin session", path: "/session", + request: { + query: z.object({ + passive: z.literal("true").optional(), + }), + }, responses: { 200: { content: { @@ -41,6 +46,8 @@ export const sessionAdminRoute = buildRoute({ ), }), vitnode_version: z.string(), + expiresAt: z.date(), + signOutWhenTabsClose: z.boolean(), }), }, }, @@ -53,7 +60,8 @@ export const sessionAdminRoute = buildRoute({ }, handler: async c => { const user = c.get("admin")?.user; - if (!user) throw new HTTPException(403); + const expiresAt = c.get("adminSessionExpiresAt"); + if (!(user && expiresAt)) throw new HTTPException(403); const permissions = await resolveStaffPermissions(c, { type: "admin", @@ -64,6 +72,9 @@ export const sessionAdminRoute = buildRoute({ user, permissions, vitnode_version: CONFIG_PLUGIN.version, + expiresAt, + signOutWhenTabsClose: + c.get("core").authorization.cookieDomain === undefined, }); }, }); diff --git a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts index 29005b75d..ad5c6aca9 100644 --- a/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts +++ b/packages/vitnode/src/api/modules/users/passkeys/passkeys.test.ts @@ -25,6 +25,14 @@ const RP_ID = "example.com"; type Authorization = EnvVariablesVitNode["core"]["authorization"]; +const GITHUB: Authorization["ssoAdapters"][number] = { + fetchToken: vi.fn(), + fetchUser: vi.fn(), + getUrl: () => "https://github.com/login/oauth/authorize", + id: "github", + name: "GitHub", +}; + const AUTHORIZATION: Authorization = { adminCookieExpires: 1000 * 60 * 60 * 24, adminCookieName: "vitnode_auth_admin", @@ -41,7 +49,7 @@ const AUTHORIZATION: Authorization = { rpName: "VitNode", }, password: { enabled: true }, - ssoAdapters: [], + ssoAdapters: [GITHUB], }; const ALICE = { email: "alice@example.com", id: 1, name: "Alice" }; @@ -70,8 +78,8 @@ const json = (body: unknown): RequestInit => ({ const harness = ({ accounts = { - [ALICE.id]: { hasPassword: true, ssoAccounts: 0 }, - [BOB.id]: { hasPassword: true, ssoAccounts: 0 }, + [ALICE.id]: { hasPassword: true, ssoProviders: [] }, + [BOB.id]: { hasPassword: true, ssoProviders: [] }, }, passkeys: initialPasskeys = AUTHORIZATION.passkeys, }: { @@ -96,11 +104,13 @@ const harness = ({ "checkIfUserIsAdmin", ).mockImplementation(async userId => Promise.resolve(staff.has(userId))); let adminViewer: null | Viewer = null; - vi.spyOn(SessionAdminModel.prototype, "getUser").mockImplementation( + vi.spyOn(SessionAdminModel.prototype, "getSession").mockImplementation( async () => Promise.resolve( - adminViewer as unknown as Awaited< - ReturnType + (adminViewer + ? { expiresAt: new Date(Date.now() + 60_000), user: adminViewer } + : null) as unknown as Awaited< + ReturnType >, ), ); @@ -745,7 +755,7 @@ describe("passkey management", () => { it("keeps the last passkey of an account with no other way to sign in", async () => { const { alicePasskeyId, h } = await withPasskeys({ - [ALICE.id]: { hasPassword: false, ssoAccounts: 0 }, + [ALICE.id]: { hasPassword: false, ssoProviders: [] }, }); h.signInAs(ALICE); @@ -760,7 +770,7 @@ describe("passkey management", () => { it("lets a passwordless account delete its last passkey when SSO is linked", async () => { const { alicePasskeyId, h } = await withPasskeys({ - [ALICE.id]: { hasPassword: false, ssoAccounts: 1 }, + [ALICE.id]: { hasPassword: false, ssoProviders: ["github"] }, }); h.signInAs(ALICE); @@ -771,6 +781,21 @@ describe("passkey management", () => { expect(response.status).toBe(200); }); + it("ignores SSO links to a provider that is no longer configured", async () => { + const { alicePasskeyId, h } = await withPasskeys({ + [ALICE.id]: { hasPassword: false, ssoProviders: ["gitlab"] }, + }); + h.signInAs(ALICE); + + const response = await h.request(`/${alicePasskeyId}`, { + method: "DELETE", + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "last_recovery_method" }); + expect(h.passkeys.has(alicePasskeyId)).toBe(true); + }); + it("closes every management route when passkeys are switched off", async () => { const { alicePasskeyId, h } = await withPasskeys(); h.signInAs(ALICE); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index a291751ef..249284fa0 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1417,6 +1417,16 @@ "desc": "Search admin pages and users, or jump straight to a member.", "placeholder": "Search pages and users...", "hint": "Type at least {count} characters to search users." + }, + "session": { + "expired": { + "title": "Your AdminCP session expired", + "desc": "You were signed out after a period of inactivity. Sign in again to pick up where you left off." + }, + "tabs_closed": { + "title": "Please sign in again", + "desc": "All AdminCP tabs were closed, so we signed you out to keep your account safe." + } } }, "advanced": { diff --git a/packages/vitnode/src/pages/login/reset-password.tsx b/packages/vitnode/src/pages/login/reset-password.tsx index 9e32e6311..49c15ff12 100644 --- a/packages/vitnode/src/pages/login/reset-password.tsx +++ b/packages/vitnode/src/pages/login/reset-password.tsx @@ -4,7 +4,7 @@ import type { PluginRoutePageProps } from "@/routing"; import type { PasswordResetSearch } from "@/tanstack/auth/recovery"; import type { PasswordResetRouteData } from "@/tanstack/auth/recovery-route"; -import { middlewareConfigQueryOptions } from "@/tanstack/auth/middleware-config"; +import { loadMiddlewareConfig } from "@/tanstack/auth/middleware-config"; import { passwordRecoveryAvailability, PasswordRecoveryUnknownError, @@ -37,10 +37,7 @@ export const route = defineRoute({ */ load: async ({ context, search }) => { const availability = passwordRecoveryAvailability( - await context.queryClient.query({ - ...middlewareConfigQueryOptions(), - staleTime: "static", - }), + await loadMiddlewareConfig(context.queryClient), ); // Not a 404: the route exists, the API could not say whether the flow does. diff --git a/packages/vitnode/src/pages/settings/security.tsx b/packages/vitnode/src/pages/settings/security.tsx index 863cce726..736d5396c 100644 --- a/packages/vitnode/src/pages/settings/security.tsx +++ b/packages/vitnode/src/pages/settings/security.tsx @@ -2,7 +2,10 @@ import { notFound } from "@tanstack/react-router"; import type { PluginRoutePageProps } from "@/routing"; -import { middlewareConfigQueryOptions } from "@/tanstack/auth/middleware-config"; +import { + loadMiddlewareConfig, + MiddlewareConfigUnknownError, +} from "@/tanstack/auth/middleware-config"; import { PasskeysPanelContent } from "@/tanstack/passkeys/panel"; import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; @@ -21,10 +24,9 @@ const SecurityPage = ({ loaderData }: PluginRoutePageProps) => ( export const route = defineAuthenticatedRoute({ load: async ({ context }) => { - const config = await context.queryClient.query({ - ...middlewareConfigQueryOptions(), - staleTime: "static", - }); + const config = await loadMiddlewareConfig(context.queryClient); + + if (!config.isKnown) throw new MiddlewareConfigUnknownError(); // eslint-disable-next-line @typescript-eslint/only-throw-error if (!config.passkeys) throw notFound(); diff --git a/packages/vitnode/src/tanstack/admin/actions.ts b/packages/vitnode/src/tanstack/admin/actions.ts index 2aad11131..a2052767b 100644 --- a/packages/vitnode/src/tanstack/admin/actions.ts +++ b/packages/vitnode/src/tanstack/admin/actions.ts @@ -10,6 +10,7 @@ import type { AuthNavigate } from "../auth/actions"; import { signInFormResult } from "../auth/screens"; import { authTransport } from "../auth/transport"; import { removeAdminIdentityQueries } from "./queries"; +import { markAdminTabAlive } from "./tab-presence"; export const useAdminSignInAction = ({ destination, @@ -26,6 +27,7 @@ export const useAdminSignInAction = ({ if (!result.ok) return signInFormResult(result); removeAdminIdentityQueries(queryClient); + markAdminTabAlive(); await navigate(destination()); return undefined; @@ -61,6 +63,7 @@ export const useAdminPasskeySignInAction = ({ } removeAdminIdentityQueries(queryClient); + markAdminTabAlive(); await navigate(destination()); return undefined; diff --git a/packages/vitnode/src/tanstack/admin/default-transport.ts b/packages/vitnode/src/tanstack/admin/default-transport.ts index a08fcd0cc..3505f50db 100644 --- a/packages/vitnode/src/tanstack/admin/default-transport.ts +++ b/packages/vitnode/src/tanstack/admin/default-transport.ts @@ -1,15 +1,20 @@ import { CONFIG_PLUGIN } from "@/config"; import { fetcher } from "@/tanstack/fetcher"; +import type { AdminSessionReadOptions } from "./session-read"; + import { readAdminSessionThrough } from "./session-read"; -export const readAdminSessionFromApi = async () => +export const readAdminSessionFromApi = async ({ + passive = false, +}: AdminSessionReadOptions = {}) => await readAdminSessionThrough(async () => { const response = await fetcher({ plugin: CONFIG_PLUGIN.pluginId, method: "get", module: "admin", path: "/session", + args: { query: passive ? { passive: "true" } : {} }, }); // The narrowing stays here rather than in the shared read: the route's diff --git a/packages/vitnode/src/tanstack/admin/server.ts b/packages/vitnode/src/tanstack/admin/server.ts index cb8155985..b42851c6b 100644 --- a/packages/vitnode/src/tanstack/admin/server.ts +++ b/packages/vitnode/src/tanstack/admin/server.ts @@ -12,6 +12,7 @@ export const readAdminSessionOnApi = async () => method: "get", module: "admin", path: "/session", + args: { query: {} }, }); // See `default-transport`: the `200` arm is the only one with a body, and diff --git a/packages/vitnode/src/tanstack/admin/session-expiry.test.ts b/packages/vitnode/src/tanstack/admin/session-expiry.test.ts new file mode 100644 index 000000000..3b1919b57 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/session-expiry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { + ADMIN_SESSION_MIN_CHECK_DELAY_MS, + adminSessionCheckDelay, +} from "./session-expiry"; + +describe("adminSessionCheckDelay", () => { + const now = Date.parse("2026-09-28T12:00:00.000Z"); + + it("waits until the session expires", () => { + expect(adminSessionCheckDelay("2026-09-28T13:00:00.000Z", now)).toBe( + 60 * 60_000, + ); + expect(adminSessionCheckDelay(new Date(now + 90_000), now)).toBe(90_000); + }); + + it("never checks in a tight loop for an expiry already behind the clock", () => { + expect(adminSessionCheckDelay(new Date(now - 60_000), now)).toBe( + ADMIN_SESSION_MIN_CHECK_DELAY_MS, + ); + expect(adminSessionCheckDelay("not a date", now)).toBe( + ADMIN_SESSION_MIN_CHECK_DELAY_MS, + ); + }); + + it("stays inside what setTimeout can hold", () => { + expect( + adminSessionCheckDelay(new Date(now + 1000 * 60 * 60 * 24 * 60), now), + ).toBe(2_147_483_647); + }); +}); diff --git a/packages/vitnode/src/tanstack/admin/session-expiry.ts b/packages/vitnode/src/tanstack/admin/session-expiry.ts new file mode 100644 index 000000000..bcff17280 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/session-expiry.ts @@ -0,0 +1,16 @@ +export const ADMIN_SESSION_MIN_CHECK_DELAY_MS = 10_000; + +const MAX_TIMEOUT_MS = 2_147_483_647; + +export const adminSessionCheckDelay = ( + expiresAt: Date | string, + now = Date.now(), +): number => { + const remaining = new Date(expiresAt).getTime() - now; + if (!Number.isFinite(remaining)) return ADMIN_SESSION_MIN_CHECK_DELAY_MS; + + return Math.min( + Math.max(remaining, ADMIN_SESSION_MIN_CHECK_DELAY_MS), + MAX_TIMEOUT_MS, + ); +}; diff --git a/packages/vitnode/src/tanstack/admin/session-guard.tsx b/packages/vitnode/src/tanstack/admin/session-guard.tsx new file mode 100644 index 000000000..22d9bdc64 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/session-guard.tsx @@ -0,0 +1,124 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { useSignOutAction } from "../auth/actions"; +import { useAdminAccess } from "./permissions"; +import { adminSessionCheckDelay } from "./session-expiry"; +import { ADMIN_SESSION_QUERY_KEY } from "./state"; +import { isAdminSessionStillInUse, keepAdminTabAlive } from "./tab-presence"; +import { adminTransport } from "./transport"; + +export const AdminSessionGuard = () => { + const access = useAdminAccess(); + const queryClient = useQueryClient(); + const router = useRouter(); + const signOut = useSignOutAction(); + const t = useTranslations("admin.global.session"); + const [checkRound, setCheckRound] = React.useState(0); + const lastExpiresAtRef = React.useRef(undefined); + const expiredByCheckRef = React.useRef(false); + const leavingRef = React.useRef(false); + + const expiresAt = + access.status === "granted" ? access.session.expiresAt : undefined; + const signOutWhenTabsClose = + access.status === "granted" && access.session.signOutWhenTabsClose; + + const leaveExpiredSession = React.useEffectEvent(async () => { + if (leavingRef.current) return; + leavingRef.current = true; + + const expired = + expiredByCheckRef.current || + (lastExpiresAtRef.current !== undefined && + Date.now() >= lastExpiresAtRef.current); + + if (expired) { + toast.info(t("expired.title"), { description: t("expired.desc") }); + } + + await router.invalidate(); + }); + + const checkSession = React.useEffectEvent(async () => { + const read = await adminTransport().readAdminSession({ passive: true }); + + if (read.status === "granted") { + queryClient.setQueryData(ADMIN_SESSION_QUERY_KEY, read); + setCheckRound(round => round + 1); + + return; + } + + if (read.status === "denied") { + expiredByCheckRef.current = true; + queryClient.setQueryData(ADMIN_SESSION_QUERY_KEY, read); + + return; + } + + setCheckRound(round => round + 1); + }); + + const leaveAbandonedSession = React.useEffectEvent(async () => { + if (leavingRef.current) return; + leavingRef.current = true; + + const result = await signOut({ isAdmin: true }); + if (result.ok) { + toast.info(t("tabs_closed.title"), { + description: t("tabs_closed.desc"), + }); + } + }); + + React.useEffect(() => { + if (!signOutWhenTabsClose) return; + + const presence = { + mounted: true, + stopKeepingAlive: (): void => undefined, + }; + + void isAdminSessionStillInUse().then(inUse => { + if (!presence.mounted) return; + + if (!inUse) { + void leaveAbandonedSession(); + + return; + } + + presence.stopKeepingAlive = keepAdminTabAlive(); + }); + + return () => { + presence.mounted = false; + presence.stopKeepingAlive(); + }; + }, [signOutWhenTabsClose]); + + React.useEffect(() => { + if (access.status === "denied") { + if (lastExpiresAtRef.current !== undefined) void leaveExpiredSession(); + + return; + } + + if (!expiresAt) return; + + lastExpiresAtRef.current = new Date(expiresAt).getTime(); + const timer = setTimeout(() => { + void checkSession(); + }, adminSessionCheckDelay(expiresAt)); + + return () => { + clearTimeout(timer); + }; + }, [access.status, expiresAt, checkRound]); + + return null; +}; diff --git a/packages/vitnode/src/tanstack/admin/session-read.ts b/packages/vitnode/src/tanstack/admin/session-read.ts index 6f19278c0..43b4c5c3c 100644 --- a/packages/vitnode/src/tanstack/admin/session-read.ts +++ b/packages/vitnode/src/tanstack/admin/session-read.ts @@ -5,6 +5,10 @@ import { adminSessionReadFromStatus, } from "./state"; +export interface AdminSessionReadOptions { + passive?: boolean; +} + /** * What one read of the admin session endpoint saw: the status, and the body if * the status was the one that carries a body. diff --git a/packages/vitnode/src/tanstack/admin/shell.tsx b/packages/vitnode/src/tanstack/admin/shell.tsx index 717753781..ed1eede2e 100644 --- a/packages/vitnode/src/tanstack/admin/shell.tsx +++ b/packages/vitnode/src/tanstack/admin/shell.tsx @@ -20,6 +20,7 @@ import { adminShellNamespaces } from "./intl"; import { AdminNavProvider, useAdminNav } from "./nav"; import { AdminPermissionsProvider } from "./permissions"; import { AdminSearch } from "./search"; +import { AdminSessionGuard } from "./session-guard"; import { AdminUserBar } from "./user-bar"; export const AdminShellContent = ({ @@ -47,6 +48,7 @@ export const AdminShellContent = ({ return ( + { + localStorage.clear(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("isAdminTabHeartbeatFresh", () => { + const now = 1_000_000; + + it("accepts a heartbeat from a tab that was open a moment ago", () => { + expect(isAdminTabHeartbeatFresh(String(now - 1_000), now)).toBe(true); + }); + + it("rejects a heartbeat older than the grace window", () => { + expect( + isAdminTabHeartbeatFresh(String(now - ADMIN_TAB_HEARTBEAT_FRESH_MS), now), + ).toBe(false); + }); + + it("rejects a missing, garbled or future heartbeat", () => { + expect(isAdminTabHeartbeatFresh(null, now)).toBe(false); + expect(isAdminTabHeartbeatFresh("nope", now)).toBe(false); + expect(isAdminTabHeartbeatFresh(String(now + 60_000), now)).toBe(false); + }); +}); + +describe("askOtherAdminTabs", () => { + it("hears back from an open AdminCP tab", async () => { + const stop = answerAdminTabPings(); + + await expect(askOtherAdminTabs(500)).resolves.toBe(true); + stop(); + }); + + it("gives up when no AdminCP tab answers", async () => { + await expect(askOtherAdminTabs(50)).resolves.toBe(false); + }); + + it("reports no tab when the browser cannot broadcast", async () => { + vi.stubGlobal("BroadcastChannel", undefined); + + await expect(askOtherAdminTabs(50)).resolves.toBe(false); + }); +}); + +describe("isAdminSessionStillInUse", () => { + it("is in use right after another tab's heartbeat", async () => { + markAdminTabAlive(); + + await expect(isAdminSessionStillInUse()).resolves.toBe(true); + }); + + it("is in use while a quiet background tab still answers", async () => { + localStorage.setItem( + ADMIN_TAB_HEARTBEAT_KEY, + String(Date.now() - 10 * 60_000), + ); + const stop = answerAdminTabPings(); + + await expect(isAdminSessionStillInUse()).resolves.toBe(true); + stop(); + }); + + it("is abandoned once every AdminCP tab was closed", async () => { + localStorage.setItem( + ADMIN_TAB_HEARTBEAT_KEY, + String(Date.now() - 10 * 60_000), + ); + + await expect(isAdminSessionStillInUse()).resolves.toBe(false); + }); + + it("does not sign anyone out when storage is blocked", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + + await expect(isAdminSessionStillInUse()).resolves.toBe(true); + vi.restoreAllMocks(); + }); +}); + +describe("keepAdminTabAlive", () => { + it("beats while mounted, answers pings, and beats once more on leave", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "Date"] }); + vi.setSystemTime(1_000_000); + + const stop = keepAdminTabAlive(); + expect(localStorage.getItem(ADMIN_TAB_HEARTBEAT_KEY)).toBe("1000000"); + + vi.setSystemTime(1_005_000); + vi.advanceTimersByTime(5_000); + expect(localStorage.getItem(ADMIN_TAB_HEARTBEAT_KEY)).toBe("1010000"); + + vi.useRealTimers(); + await expect(askOtherAdminTabs(500)).resolves.toBe(true); + + stop(); + await expect(askOtherAdminTabs(50)).resolves.toBe(false); + }); +}); diff --git a/packages/vitnode/src/tanstack/admin/tab-presence.ts b/packages/vitnode/src/tanstack/admin/tab-presence.ts new file mode 100644 index 000000000..94056adfe --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/tab-presence.ts @@ -0,0 +1,111 @@ +export const ADMIN_TAB_HEARTBEAT_KEY = "vitnode:admin-tab-heartbeat"; + +export const ADMIN_TAB_CHANNEL = "vitnode:admin-tabs"; + +export const ADMIN_TAB_HEARTBEAT_INTERVAL_MS = 5_000; + +export const ADMIN_TAB_HEARTBEAT_FRESH_MS = 15_000; + +export const ADMIN_TAB_PING_TIMEOUT_MS = 750; + +const PING = "ping"; +const PONG = "pong"; + +export const isAdminTabHeartbeatFresh = ( + heartbeat: null | string, + now = Date.now(), +): boolean => { + const beatAt = Number(heartbeat); + if (!(heartbeat && Number.isFinite(beatAt))) return false; + + const age = now - beatAt; + + return age >= 0 && age < ADMIN_TAB_HEARTBEAT_FRESH_MS; +}; + +export type AdminTabHeartbeat = + { available: false } | { available: true; heartbeat: null | string }; + +export const readAdminTabHeartbeat = (): AdminTabHeartbeat => { + try { + return { + available: true, + heartbeat: localStorage.getItem(ADMIN_TAB_HEARTBEAT_KEY), + }; + } catch { + return { available: false }; + } +}; + +export const markAdminTabAlive = (now = Date.now()): boolean => { + try { + localStorage.setItem(ADMIN_TAB_HEARTBEAT_KEY, String(now)); + + return true; + } catch { + return false; + } +}; + +export const askOtherAdminTabs = async ( + timeoutMs = ADMIN_TAB_PING_TIMEOUT_MS, +): Promise => { + if (typeof BroadcastChannel === "undefined") return false; + + const channel = new BroadcastChannel(ADMIN_TAB_CHANNEL); + + return await new Promise(resolve => { + const finish = (answered: boolean) => { + clearTimeout(timer); + channel.close(); + resolve(answered); + }; + const timer = setTimeout(() => { + finish(false); + }, timeoutMs); + + channel.onmessage = (event: MessageEvent) => { + if (event.data === PONG) finish(true); + }; + channel.postMessage(PING); + }); +}; + +export const answerAdminTabPings = (): (() => void) => { + if (typeof BroadcastChannel === "undefined") return () => undefined; + + const channel = new BroadcastChannel(ADMIN_TAB_CHANNEL); + channel.onmessage = (event: MessageEvent) => { + if (event.data === PING) channel.postMessage(PONG); + }; + + return () => { + channel.close(); + }; +}; + +export const isAdminSessionStillInUse = async (): Promise => { + const read = readAdminTabHeartbeat(); + if (!read.available) return true; + if (isAdminTabHeartbeatFresh(read.heartbeat)) return true; + + return await askOtherAdminTabs(); +}; + +export const keepAdminTabAlive = (): (() => void) => { + const beat = () => { + markAdminTabAlive(); + }; + + beat(); + const interval = setInterval(beat, ADMIN_TAB_HEARTBEAT_INTERVAL_MS); + const stopAnswering = answerAdminTabPings(); + window.addEventListener("pagehide", beat); + + return () => { + clearInterval(interval); + stopAnswering(); + window.removeEventListener("pagehide", beat); + beat(); + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/transport.ts b/packages/vitnode/src/tanstack/admin/transport.ts index 7aec06d19..da43f439f 100644 --- a/packages/vitnode/src/tanstack/admin/transport.ts +++ b/packages/vitnode/src/tanstack/admin/transport.ts @@ -1,9 +1,12 @@ import type { AdminSessionReadResult } from "./session-api"; +import type { AdminSessionReadOptions } from "./session-read"; import { defaultAdminTransport } from "./default-transport"; export interface AdminTransport { - readAdminSession: () => Promise; + readAdminSession: ( + options?: AdminSessionReadOptions, + ) => Promise; } let registered: AdminTransport | undefined; diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts new file mode 100644 index 000000000..ae59be621 --- /dev/null +++ b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts @@ -0,0 +1,55 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + knownMiddlewareConfig, + loadMiddlewareConfig, + middlewareConfigQueryOptions, + type MiddlewareConfigState, + UNKNOWN_MIDDLEWARE_CONFIG, +} from "./middleware-config"; + +const { queryKey } = middlewareConfigQueryOptions(); + +const seeded = (data: MiddlewareConfigState) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKey, data); + + return queryClient; +}; + +const fetchesOf = (queryClient: QueryClient) => + queryClient.getQueryState(queryKey)?.dataUpdateCount; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("loadMiddlewareConfig", () => { + it("asks the API again instead of trusting a cached fallback", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const queryClient = seeded(UNKNOWN_MIDDLEWARE_CONFIG); + + await loadMiddlewareConfig(queryClient); + + expect(fetchesOf(queryClient)).toBe(2); + }); + + it("serves a known configuration from the cache", async () => { + const known = knownMiddlewareConfig({ + ai: { models: [] }, + bottomBar: [], + isEmail: true, + navigation: [], + passkeys: false, + password: true, + sso: [], + }); + const queryClient = seeded(known); + + const config = await loadMiddlewareConfig(queryClient); + + expect(fetchesOf(queryClient)).toBe(1); + expect(config).toEqual(known); + }); +}); diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts index 1de1babb6..cb6ea03b2 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts @@ -64,6 +64,21 @@ export const middlewareConfigQueryOptions = () => staleTime: MIDDLEWARE_STALE_TIME, }); +export const loadMiddlewareConfig = async ( + queryClient: QueryClient, +): Promise => + await queryClient.query({ + ...middlewareConfigQueryOptions(), + staleTime: query => (query.state.data?.isKnown ? "static" : 0), + }); + +export class MiddlewareConfigUnknownError extends Error { + constructor() { + super("The deployment configuration could not be read."); + this.name = "MiddlewareConfigUnknownError"; + } +} + export const useMiddlewareConfigQuery = () => useSuspenseQuery(middlewareConfigQueryOptions()); diff --git a/packages/vitnode/src/tanstack/auth/sso-screen.tsx b/packages/vitnode/src/tanstack/auth/sso-screen.tsx index cf1f57f39..6c09bf7a1 100644 --- a/packages/vitnode/src/tanstack/auth/sso-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-screen.tsx @@ -6,7 +6,11 @@ import { useSSOCallback } from "@/views/auth/sso/callback/use-sso-callback"; import { RouteMessages } from "../i18n/route-messages"; import { useCompleteSsoAction, useLinkSsoAction } from "./actions"; import { parseSsoCallback } from "./contract"; -import { ssoProvidersOf, useMiddlewareConfigQuery } from "./middleware-config"; +import { + authMethodsOf, + ssoProvidersOf, + useMiddlewareConfigQuery, +} from "./middleware-config"; import { parseInternalDestination, postAuthDestination } from "./redirects"; import { SSO_CALLBACK_NAMESPACES } from "./sso-route"; @@ -53,7 +57,7 @@ export const SsoCallbackRouteContent = ({ onLink={linkSso} providerId={providerId} providers={ssoProvidersOf(config)} - showResetPassword={config.isEmail} + showResetPassword={authMethodsOf(config).resetPassword} state={state} /> diff --git a/packages/vitnode/src/tests/passkey-store.ts b/packages/vitnode/src/tests/passkey-store.ts index 820130cfe..d18b0730f 100644 --- a/packages/vitnode/src/tests/passkey-store.ts +++ b/packages/vitnode/src/tests/passkey-store.ts @@ -6,7 +6,7 @@ import type { export interface MemoryPasskeyAccount { hasPassword: boolean; - ssoAccounts: number; + ssoProviders: string[]; } export const createMemoryPasskeyStore = ( @@ -60,19 +60,22 @@ export const createMemoryPasskeyStore = ( await Promise.resolve(); }, - deletePasskey: async ({ canDelete, id, userId }) => { + deletePasskey: async ({ canDelete, id, ssoProviderIds, userId }) => { const passkey = passkeys.get(id); if (passkey?.userId !== userId) return Promise.resolve("not_found"); - const account = accounts[userId] ?? { + const { hasPassword, ssoProviders } = accounts[userId] ?? { hasPassword: false, - ssoAccounts: 0, + ssoProviders: [], }; const otherPasskeys = [...passkeys.values()].filter( other => other.userId === userId && other.id !== id, ).length; + const ssoAccounts = ssoProviders.filter(providerId => + ssoProviderIds.includes(providerId), + ).length; - if (!canDelete({ ...account, otherPasskeys })) { + if (!canDelete({ hasPassword, otherPasskeys, ssoAccounts })) { return Promise.resolve("blocked"); } passkeys.delete(id);