enforce max session length

This commit is contained in:
miloschwartz
2025-10-24 16:14:21 -07:00
parent 629f17294a
commit 39d6b93d42
12 changed files with 249 additions and 81 deletions

View File

@@ -39,7 +39,8 @@ export async function createSession(
const session: Session = {
sessionId: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
issuedAt: new Date().getTime()
};
await db.insert(sessions).values(session);
return session;

View File

@@ -26,7 +26,8 @@ export const orgs = pgTable("orgs", {
name: varchar("name").notNull(),
subnet: varchar("subnet"),
createdAt: text("createdAt"),
requireTwoFactor: boolean("requireTwoFactor").default(false)
requireTwoFactor: boolean("requireTwoFactor"),
maxSessionLengthHours: integer("maxSessionLengthHours")
});
export const orgDomains = pgTable("orgDomains", {
@@ -226,7 +227,8 @@ export const sessions = pgTable("session", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
issuedAt: bigint("expiresAt", { mode: "number" })
});
export const newtSessions = pgTable("newtSession", {

View File

@@ -19,7 +19,8 @@ export const orgs = sqliteTable("orgs", {
name: text("name").notNull(),
subnet: text("subnet"),
createdAt: text("createdAt"),
requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" })
requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }),
maxSessionLengthHours: integer("maxSessionLengthHours") // hours
});
export const userDomains = sqliteTable("userDomains", {
@@ -333,7 +334,8 @@ export const sessions = sqliteTable("session", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: integer("expiresAt").notNull()
expiresAt: integer("expiresAt").notNull(),
issuedAt: integer("issuedAt")
});
export const newtSessions = sqliteTable("newtSession", {

View File

@@ -1,10 +1,12 @@
import { Org, User } from "@server/db";
import { Org, Session, User } from "@server/db";
export type CheckOrgAccessPolicyProps = {
orgId?: string;
org?: Org;
userId?: string;
user?: User;
sessionId?: string;
session?: Session;
};
export type CheckOrgAccessPolicyResult = {
@@ -12,6 +14,11 @@ export type CheckOrgAccessPolicyResult = {
error?: string;
policies?: {
requiredTwoFactor?: boolean;
maxSessionLength?: {
compliant: boolean;
maxSessionLengthHours: number;
sessionAgeHours: number;
}
};
};

View File

@@ -49,7 +49,8 @@ export async function verifyOrgAccess(
const policyCheck = await checkOrgAccessPolicy({
orgId,
userId
userId,
session: req.session
});
logger.debug("Org check policy result", { policyCheck });

View File

@@ -12,7 +12,7 @@
*/
import { build } from "@server/build";
import { db, Org, orgs, User, users } from "@server/db";
import { db, Org, orgs, sessions, User, users } from "@server/db";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import license from "#private/license/license";
@@ -28,6 +28,7 @@ export async function checkOrgAccessPolicy(
): Promise<CheckOrgAccessPolicyResult> {
const userId = props.userId || props.user?.userId;
const orgId = props.orgId || props.org?.orgId;
const sessionId = props.sessionId || props.session?.sessionId;
if (!orgId) {
return {
@@ -38,6 +39,9 @@ export async function checkOrgAccessPolicy(
if (!userId) {
return { allowed: false, error: "User ID is required" };
}
if (!sessionId) {
return { allowed: false, error: "Session ID is required" };
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
@@ -80,6 +84,17 @@ export async function checkOrgAccessPolicy(
}
}
if (!props.session) {
const [sessionQuery] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionId, sessionId));
props.session = sessionQuery;
if (!props.session) {
return { allowed: false, error: "Session not found" };
}
}
// now check the policies
const policies: CheckOrgAccessPolicyResult["policies"] = {};
@@ -88,7 +103,34 @@ export async function checkOrgAccessPolicy(
policies.requiredTwoFactor = props.user.twoFactorEnabled || false;
}
const allowed = Object.values(policies).every((v) => v === true);
if (props.org.maxSessionLengthHours) {
const sessionIssuedAt = props.session.issuedAt; // may be null
const maxSessionLengthHours = props.org.maxSessionLengthHours;
if (sessionIssuedAt) {
const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000;
const sessionAgeMs = Date.now() - sessionIssuedAt;
policies.maxSessionLength = {
compliant: sessionAgeMs <= maxSessionLengthMs,
maxSessionLengthHours,
sessionAgeHours: sessionAgeMs / (60 * 60 * 1000)
};
} else {
policies.maxSessionLength = {
compliant: false,
maxSessionLengthHours,
sessionAgeHours: maxSessionLengthHours
};
}
}
let allowed = true;
if (policies.requiredTwoFactor === false) {
allowed = false;
}
if (policies.maxSessionLength && policies.maxSessionLength.compliant === false) {
allowed = false;
}
return {
allowed,

View File

@@ -68,7 +68,6 @@ export async function checkOrgUserAccess(
next: NextFunction
): Promise<any> {
try {
logger.debug("here0 ")
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
@@ -116,7 +115,8 @@ export async function checkOrgUserAccess(
const policyCheck = await checkOrgAccessPolicy({
orgId,
userId
userId,
session: req.session
});
// if we get here, the user has an org join, we just don't know if they pass the policies

View File

@@ -24,7 +24,8 @@ const updateOrgParamsSchema = z
const updateOrgBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
requireTwoFactor: z.boolean().optional()
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@@ -80,6 +81,7 @@ export async function updateOrg(
const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) {
parsedBody.data.requireTwoFactor = undefined;
parsedBody.data.maxSessionLengthHours = undefined;
}
if (
@@ -100,7 +102,8 @@ export async function updateOrg(
.update(orgs)
.set({
name: parsedBody.data.name,
requireTwoFactor: parsedBody.data.requireTwoFactor
requireTwoFactor: parsedBody.data.requireTwoFactor,
maxSessionLengthHours: parsedBody.data.maxSessionLengthHours
})
.where(eq(orgs.orgId, orgId))
.returning();

View File

@@ -76,7 +76,8 @@ export async function getExchangeToken(
// check org policy here
const hasAccess = await checkOrgAccessPolicy({
orgId: resource[0].orgId,
userId: req.user!.userId
userId: req.user!.userId,
session: req.session
});
if (!hasAccess.allowed || hasAccess.error) {