fosrl.pangolin/server/routers/idp/oidcAutoProvision.ts
2025-04-28 21:14:09 -04:00

233 lines
6.9 KiB
TypeScript

// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import {
createSession,
generateId,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import db from "@server/db";
import { Idp, idpOrg, orgs, roles, User, userOrgs, users } from "@server/db/schemas";
import logger from "@server/logger";
import { UserType } from "@server/types/UserTypes";
import { eq, and, inArray } from "drizzle-orm";
import jmespath from "jmespath";
import { Request, Response } from "express";
export async function oidcAutoProvision({
idp,
claims,
existingUser,
userIdentifier,
email,
name,
req,
res
}: {
idp: Idp;
claims: any;
existingUser?: User;
userIdentifier: string;
email?: string;
name?: string;
req: Request;
res: Response;
}) {
const allOrgs = await db.select().from(orgs);
const defaultRoleMapping = idp.defaultRoleMapping;
const defaultOrgMapping = idp.defaultOrgMapping;
let userOrgInfo: { orgId: string; roleId: number }[] = [];
for (const org of allOrgs) {
const [idpOrgRes] = await db
.select()
.from(idpOrg)
.where(
and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, org.orgId))
);
let roleId: number | undefined = undefined;
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping(orgMapping, org.orgId);
if (hydratedOrgMapping) {
logger.debug("Hydrated Org Mapping", {
hydratedOrgMapping
});
const orgId = jmespath.search(claims, hydratedOrgMapping);
logger.debug("Extraced Org ID", { orgId });
if (orgId !== true && orgId !== org.orgId) {
// user not allowed to access this org
continue;
}
}
const roleMapping = idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) {
logger.debug("Role Mapping", { roleMapping });
const roleName = jmespath.search(claims, roleMapping);
if (!roleName) {
logger.error("Role name not found in the ID token", {
roleName
});
continue;
}
const [roleRes] = await db
.select()
.from(roles)
.where(
and(eq(roles.orgId, org.orgId), eq(roles.name, roleName))
);
if (!roleRes) {
logger.error("Role not found", {
orgId: org.orgId,
roleName
});
continue;
}
roleId = roleRes.roleId;
userOrgInfo.push({
orgId: org.orgId,
roleId
});
}
}
logger.debug("User org info", { userOrgInfo });
let existingUserId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
await trx.insert(users).values({
userId,
username: userIdentifier,
email: email || null,
name: name || null,
type: UserType.OIDC,
idpId: idp.idpId,
emailVerified: true, // OIDC users are always verified
dateCreated: new Date().toISOString()
});
} else {
// set the name and email
await trx
.update(users)
.set({
username: userIdentifier,
email: email || null,
name: name || null
})
.where(eq(users.userId, userId!));
}
existingUserId = userId;
// get all current user orgs
const currentUserOrgs = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.userId, userId!));
// Delete orgs that are no longer valid
const orgsToDelete = currentUserOrgs.filter(
(currentOrg) =>
!userOrgInfo.some((newOrg) => newOrg.orgId === currentOrg.orgId)
);
if (orgsToDelete.length > 0) {
await trx.delete(userOrgs).where(
and(
eq(userOrgs.userId, userId!),
inArray(
userOrgs.orgId,
orgsToDelete.map((org) => org.orgId)
)
)
);
}
// Update roles for existing orgs where the role has changed
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
const newOrg = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
return newOrg && newOrg.roleId !== currentOrg.roleId;
});
if (orgsToUpdate.length > 0) {
for (const org of orgsToUpdate) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === org.orgId
);
if (newRole) {
await trx
.update(userOrgs)
.set({ roleId: newRole.roleId })
.where(
and(
eq(userOrgs.userId, userId!),
eq(userOrgs.orgId, org.orgId)
)
);
}
}
}
// Add new orgs that don't exist yet
const orgsToAdd = userOrgInfo.filter(
(newOrg) =>
!currentUserOrgs.some(
(currentOrg) => currentOrg.orgId === newOrg.orgId
)
);
if (orgsToAdd.length > 0) {
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({
userId: userId!,
orgId: org.orgId,
roleId: org.roleId,
dateCreated: new Date().toISOString()
}))
);
}
});
const token = generateSessionToken();
const sess = await createSession(token, existingUserId!);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
}
function hydrateOrgMapping(
orgMapping: string | null,
orgId: string
): string | undefined {
if (!orgMapping) {
return undefined;
}
return orgMapping.split("{{orgId}}").join(orgId);
}