diff --git a/server/auth/actions.ts b/server/auth/actions.ts index b825b64..251abb4 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export enum ActionsEnum { + createOrgUser = "createOrgUser", listOrgs = "listOrgs", listUserOrgs = "listUserOrgs", createOrg = "createOrg", diff --git a/server/routers/external.ts b/server/routers/external.ts index 7cc6b49..b543faa 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -26,7 +26,12 @@ import { verifyUserAccess, getUserOrgs, verifyUserIsServerAdmin, +<<<<<<< Updated upstream verifyIsLoggedInUser +======= + verifyIsLoggedInUser, + verifyClientAccess +>>>>>>> Stashed changes } from "@server/middlewares"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -46,6 +51,10 @@ unauthenticated.get("/", (_, res) => { export const authenticated = Router(); authenticated.use(verifySessionUserMiddleware); +<<<<<<< Updated upstream +======= +authenticated.get("/pick-org-defaults", org.pickOrgDefaults); +>>>>>>> Stashed changes authenticated.get("/org/checkId", org.checkId); authenticated.put("/org", getUserOrgs, org.createOrg); @@ -448,7 +457,15 @@ authenticated.delete( user.adminRemoveUser ); +authenticated.put( + "/org/:orgId/user", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createOrgUser), + user.createOrgUser +); + authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); + authenticated.get( "/org/:orgId/users", verifyOrgAccess, diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 43c4c7f..006c14a 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -5,11 +5,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { - idp, - idpOidcConfig, - users -} from "@server/db/schemas"; +import { idp, idpOidcConfig, users } from "@server/db/schemas"; import { and, eq } from "drizzle-orm"; import * as arctic from "arctic"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; @@ -17,6 +13,12 @@ import jmespath from "jmespath"; import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; +import { + createSession, + generateSessionToken, + serializeSessionCookie +} from "@server/auth/sessions/app"; +import { response } from "@server/lib"; const paramsSchema = z .object({ @@ -213,31 +215,31 @@ export async function validateOidcCallback( return next( createHttpError( HttpCode.UNAUTHORIZED, - "User not found in the IdP" + "User not provisioned in the system" ) ); } - // - // const token = generateSessionToken(); - // const sess = await createSession(token, existingUser.userId); - // const isSecure = req.protocol === "https"; - // const cookie = serializeSessionCookie( - // token, - // isSecure, - // new Date(sess.expiresAt) - // ); - // - // res.appendHeader("Set-Cookie", cookie); - // - // return response(res, { - // data: { - // redirectUrl: postAuthRedirectUrl - // }, - // success: true, - // error: false, - // message: "OIDC callback validated successfully", - // status: HttpCode.CREATED - // }); + + const token = generateSessionToken(); + const sess = await createSession(token, existingUser.userId); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, + new Date(sess.expiresAt) + ); + + res.appendHeader("Set-Cookie", cookie); + + return response(res, { + data: { + redirectUrl: postAuthRedirectUrl + }, + success: true, + error: false, + message: "OIDC callback validated successfully", + status: HttpCode.CREATED + }); } } catch (error) { logger.error(error); @@ -246,13 +248,3 @@ export async function validateOidcCallback( ); } } - -function hydrateOrgMapping( - orgMapping: string | null, - orgId: string -): string | undefined { - if (!orgMapping) { - return undefined; - } - return orgMapping.split("{{orgId}}").join(orgId); -} diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts new file mode 100644 index 0000000..3ca2a5a --- /dev/null +++ b/server/routers/user/createOrgUser.ts @@ -0,0 +1,207 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import db from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db/schemas"; +import { generateId } from "@server/auth/sessions/app"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty() + }) + .strict(); + +const bodySchema = z + .object({ + email: z.string().email().optional(), + username: z.string().nonempty(), + name: z.string().optional(), + type: z.enum(["internal", "oidc"]).optional(), + idpId: z.number().optional(), + roleId: z.number() + }) + .strict(); + +export type CreateOrgUserResponse = {}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/user", + description: "Create an organization user.", + tags: [OpenAPITags.User, OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createOrgUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { username, email, name, type, idpId, roleId } = parsedBody.data; + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Role ID not found") + ); + } + + if (type === "internal") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Internal users are not supported yet" + ) + ); + } else if (type === "oidc") { + if (!idpId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IDP ID is required for OIDC users" + ) + ); + } + + const [idpRes] = await db + .select() + .from(idp) + .innerJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) + .where(eq(idp.idpId, idpId)); + + if (!idpRes) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "IDP ID not found") + ); + } + + if (idpRes.idp.type !== "oidc") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IDP ID is not of type OIDC" + ) + ); + } + + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.username, username)); + + if (existingUser) { + const [existingOrgUser] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, existingUser.userId) + ) + ); + + if (existingOrgUser) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User already exists in this organization" + ) + ); + } + + await db + .insert(userOrgs) + .values({ + orgId, + userId: existingUser.userId, + roleId: role.roleId + }) + .returning(); + } else { + const userId = generateId(15); + + const [newUser] = await db + .insert(users) + .values({ + userId: userId, + email, + username, + name, + type: "oidc", + idpId, + dateCreated: new Date().toISOString(), + emailVerified: true + }) + .returning(); + + await db + .insert(userOrgs) + .values({ + orgId, + userId: newUser.userId, + roleId: role.roleId + }) + .returning(); + } + } else { + return next( + createHttpError(HttpCode.BAD_REQUEST, "User type is required") + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Org user created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 8e8fd39..49278c1 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -9,3 +9,4 @@ export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./listInvitations"; export * from "./removeInvitation"; +export * from "./createOrgUser"; diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx index 0048c23..ee3104b 100644 --- a/src/app/admin/idp/AdminIdpTable.tsx +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -153,22 +153,6 @@ export default function IdpTable({ idps }: Props) { ); } }, - { - accessorKey: "orgCount", - header: ({ column }) => { - return ( - - ); - } - }, { id: "actions", cell: ({ row }) => { diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index 760f590..4f4ad61 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -41,6 +41,7 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { Badge } from "@app/components/ui/badge"; const GeneralFormSchema = z.object({ name: z.string().min(2, { message: "Name must be at least 2 characters." }), @@ -218,20 +219,28 @@ export default function GeneralPage() { )} /> - { - form.setValue("autoProvision", checked); - }} - /> +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> + Enterprise +
When enabled, users will be automatically - created in the system upon first login using - this identity provider. + created in the system upon first login with + the ability to map users to roles and + organizations. diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 1d695f4..45b30ac 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -35,6 +35,7 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon, ExternalLink } from "lucide-react"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; +import { Badge } from "@app/components/ui/badge"; const createIdpFormSchema = z.object({ name: z.string().min(2, { message: "Name must be at least 2 characters." }), @@ -87,7 +88,7 @@ export default function Page() { namePath: "name", emailPath: "email", scopes: "openid profile email", - autoProvision: true + autoProvision: false } }); @@ -182,24 +183,30 @@ export default function Page() { )} /> - { - form.setValue( - "autoProvision", - checked - ); - }} - /> +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> + + Enterprise + +
When enabled, users will be automatically created in the system upon - first login using this identity - provider. + first login with the ability to map + users to roles and organizations. diff --git a/src/components/SwitchInput.tsx b/src/components/SwitchInput.tsx index e1bd725..571a1ab 100644 --- a/src/components/SwitchInput.tsx +++ b/src/components/SwitchInput.tsx @@ -7,6 +7,7 @@ interface SwitchComponentProps { label: string; description?: string; defaultChecked?: boolean; + disabled?: boolean; onCheckedChange: (checked: boolean) => void; } @@ -14,6 +15,7 @@ export function SwitchInput({ id, label, description, + disabled, defaultChecked = false, onCheckedChange }: SwitchComponentProps) { @@ -24,6 +26,7 @@ export function SwitchInput({ id={id} defaultChecked={defaultChecked} onCheckedChange={onCheckedChange} + disabled={disabled} />