Merge branch 'auth-providers' into dev

This commit is contained in:
miloschwartz 2025-04-23 22:08:37 -04:00
commit f4fd33b47f
No known key found for this signature in database
93 changed files with 5788 additions and 1608 deletions

View file

@ -6,6 +6,9 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
listOrgs = "listOrgs",
listUserOrgs = "listUserOrgs",
createOrg = "createOrg",
// deleteOrg = "deleteOrg",
getOrg = "getOrg",
@ -65,7 +68,16 @@ export enum ActionsEnum {
listResourceRules = "listResourceRules",
updateResourceRule = "updateResourceRule",
listOrgDomains = "listOrgDomains",
createNewt = "createNewt"
createNewt = "createNewt",
createIdp = "createIdp",
updateIdp = "updateIdp",
deleteIdp = "deleteIdp",
listIdps = "listIdps",
getIdp = "getIdp",
createIdpOrg = "createIdpOrg",
deleteIdpOrg = "deleteIdpOrg",
listIdpOrgs = "listIdpOrgs",
updateIdpOrg = "updateIdpOrg"
}
export async function checkUserActionPermission(

View file

@ -111,8 +111,14 @@ export const exitNodes = sqliteTable("exitNodes", {
export const users = sqliteTable("user", {
userId: text("id").primaryKey(),
email: text("email").notNull().unique(),
passwordHash: text("passwordHash").notNull(),
email: text("email"),
username: text("username").notNull(),
name: text("name"),
type: text("type").notNull(), // "internal", "oidc"
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "cascade"
}),
passwordHash: text("passwordHash"),
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
.notNull()
.default(false),
@ -420,6 +426,38 @@ export const supporterKey = sqliteTable("supporterKey", {
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
});
// Identity Providers
export const idp = sqliteTable("idp", {
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull(),
defaultRoleMapping: text("defaultRoleMapping"),
defaultOrgMapping: text("defaultOrgMapping"),
autoProvision: integer("autoProvision", {
mode: "boolean"
})
.notNull()
.default(false)
});
// Identity Provider OAuth Configuration
export const idpOidcConfig = sqliteTable("idpOidcConfig", {
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
autoIncrement: true
}),
idpId: integer("idpId")
.notNull()
.references(() => idp.idpId, { onDelete: "cascade" }),
clientId: text("clientId").notNull(),
clientSecret: text("clientSecret").notNull(),
authUrl: text("authUrl").notNull(),
tokenUrl: text("tokenUrl").notNull(),
identifierPath: text("identifierPath").notNull(),
emailPath: text("emailPath"),
namePath: text("namePath"),
scopes: text("scopes").notNull()
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@ -455,3 +493,4 @@ export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;

View file

@ -91,7 +91,19 @@ const configSchema = z.object({
credentials: z.boolean().optional()
})
.optional(),
trust_proxy: z.boolean().optional().default(true)
trust_proxy: z.boolean().optional().default(true),
secret: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(
z
.string()
.min(
32,
"SERVER_SECRET must be at least 32 characters long"
)
)
}),
traefik: z.object({
http_entrypoint: z.string(),

40
server/lib/crypto.ts Normal file
View file

@ -0,0 +1,40 @@
import * as crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
export function encrypt(value: string, key: string): string {
const iv = crypto.randomBytes(12);
const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
const encrypted = Buffer.concat([
cipher.update(value, "utf8"),
cipher.final()
]);
const authTag = cipher.getAuthTag();
return [
iv.toString("base64"),
encrypted.toString("base64"),
authTag.toString("base64")
].join(":");
}
export function decrypt(encryptedValue: string, key: string): string {
const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":");
const iv = Buffer.from(ivB64, "base64");
const encrypted = Buffer.from(encryptedB64, "base64");
const authTag = Buffer.from(authTagB64, "base64");
const keyBuffer = Buffer.from(key, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return decrypted.toString("utf8");
}

View file

@ -0,0 +1,8 @@
import config from "@server/lib/config";
export function generateOidcRedirectUrl(idpId: number) {
const dashboardUrl = config.getRawConfig().app.dashboard_url;
const redirectPath = `/auth/idp/${idpId}/oidc/callback`;
const redirectUrl = new URL(redirectPath, dashboardUrl).toString();
return redirectUrl;
}

View file

@ -14,4 +14,5 @@ export * from "./verifyAdmin";
export * from "./verifySetResourceUsers";
export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";

View file

@ -0,0 +1,44 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyIsLoggedInUser(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const reqUserId =
req.params.userId || req.body.userId || req.query.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
// allow server admins to access any user
if (req.user?.serverAdmin) {
return next();
}
if (reqUserId !== userId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User only has access to their own account"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error checking if user has access to this user"
)
);
}
}

View file

@ -11,5 +11,6 @@ export enum OpenAPITags {
Invitation = "Invitation",
Target = "Target",
Rule = "Rule",
AccessToken = "Access Token"
AccessToken = "Access Token",
Idp = "Identity Provider"
}

View file

@ -16,6 +16,7 @@ import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export const changePasswordBody = z
.object({
@ -50,6 +51,15 @@ export async function changePassword(
const { newPassword, oldPassword, code } = parsedBody.data;
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
if (newPassword === oldPassword) {
return next(
@ -62,7 +72,7 @@ export async function changePassword(
const validPassword = await verifyPassword(
oldPassword,
user.passwordHash
user.passwordHash!
);
if (!validPassword) {
return next(unauthorized());

View file

@ -14,6 +14,7 @@ import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const disable2faBody = z
.object({
@ -47,8 +48,17 @@ export async function disable2fa(
const { password, code } = parsedBody.data;
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
const validPassword = await verifyPassword(password, user.passwordHash);
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
@ -99,11 +109,11 @@ export async function disable2fa(
sendEmail(
TwoFactorAuthNotification({
email: user.email,
email: user.email!, // email is not null because we are checking user.type
enabled: false
}),
{
to: user.email,
to: user.email!,
from: config.getRawConfig().email?.no_reply,
subject: "Two-factor authentication disabled"
}

View file

@ -7,7 +7,7 @@ import db from "@server/db";
import { users } from "@server/db/schemas";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@ -17,6 +17,7 @@ import config from "@server/lib/config";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { verifySession } from "@server/auth/sessions/verifySession";
import { UserType } from "@server/types/UserTypes";
export const loginBodySchema = z
.object({
@ -69,7 +70,9 @@ export async function login(
const existingUserRes = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(eq(users.type, UserType.Internal), eq(users.email, email))
);
if (!existingUserRes || !existingUserRes.length) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
@ -88,7 +91,7 @@ export async function login(
const validPassword = await verifyPassword(
password,
existingUser.passwordHash
existingUser.passwordHash!
);
if (!validPassword) {
if (config.getRawConfig().app.log_failed_attempts) {

View file

@ -6,6 +6,7 @@ import { User } from "@server/db/schemas";
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
import config from "@server/lib/config";
import logger from "@server/logger";
import { UserType } from "@server/types/UserTypes";
export type RequestEmailVerificationCodeResponse = {
codeSent: boolean;
@ -28,6 +29,15 @@ export async function requestEmailVerificationCode(
try {
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email verification is not supported for external users"
)
);
}
if (user.emailVerified) {
return next(
createHttpError(
@ -37,7 +47,7 @@ export async function requestEmailVerificationCode(
);
}
await sendEmailVerificationCode(user.email, user.userId);
await sendEmailVerificationCode(user.email!, user.userId);
return response<RequestEmailVerificationCodeResponse>(res, {
data: {

View file

@ -74,7 +74,7 @@ export async function requestPasswordReset(
await trx.insert(passwordResetTokens).values({
userId: existingUser[0].userId,
email: existingUser[0].email,
email: existingUser[0].email!,
tokenHash,
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
});

View file

@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const requestTotpSecretBody = z
.object({
@ -46,8 +47,17 @@ export async function requestTotpSecret(
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
const validPassword = await verifyPassword(password, user.passwordHash);
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
@ -63,7 +73,7 @@ export async function requestTotpSecret(
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI("Pangolin", user.email, hex);
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
await db
.update(users)

View file

@ -8,7 +8,7 @@ import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import moment from "moment";
import {
createSession,
@ -21,6 +21,7 @@ import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export const signupBodySchema = z.object({
email: z
@ -110,7 +111,9 @@ export async function signup(
const existing = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(eq(users.email, email), eq(users.type, UserType.Internal))
);
if (existing && existing.length > 0) {
if (!config.getRawConfig().flags?.require_email_verification) {
@ -157,6 +160,8 @@ export async function signup(
await db.insert(users).values({
userId: userId,
type: UserType.Internal,
username: email,
email: email,
passwordHash,
dateCreated: moment().toISOString()

View file

@ -14,6 +14,7 @@ import logger from "@server/logger";
import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
export const verifyTotpBody = z
.object({
@ -48,6 +49,15 @@ export async function verifyTotp(
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
if (user.twoFactorEnabled) {
return next(
createHttpError(
@ -111,11 +121,11 @@ export async function verifyTotp(
sendEmail(
TwoFactorAuthNotification({
email: user.email,
email: user.email!,
enabled: true
}),
{
to: user.email,
to: user.email!,
from: config.getRawConfig().email?.no_reply,
subject: "Two-factor authentication enabled"
}

View file

@ -10,6 +10,7 @@ import * as auth from "./auth";
import * as role from "./role";
import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken";
import * as idp from "./idp";
import HttpCode from "@server/types/HttpCode";
import {
verifyAccessTokenAccess,
@ -24,7 +25,8 @@ import {
verifySetResourceUsers,
verifyUserAccess,
getUserOrgs,
verifyUserIsServerAdmin
verifyUserIsServerAdmin,
verifyIsLoggedInUser
} from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
@ -46,7 +48,10 @@ authenticated.use(verifySessionUserMiddleware);
authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
authenticated.get(
"/org/:orgId",
verifyOrgAccess,
@ -443,7 +448,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,
@ -493,6 +506,24 @@ authenticated.delete(
// createNewt
// );
authenticated.put(
"/idp/oidc",
verifyUserIsServerAdmin,
// verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
);
authenticated.post(
"/idp/:idpId/oidc",
verifyUserIsServerAdmin,
idp.updateOidcIdp
);
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
@ -582,3 +613,7 @@ authRouter.post(
);
authRouter.post("/access-token", resource.authWithAccessToken);
authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl);
authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);

View file

@ -0,0 +1,132 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
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 { idp, idpOidcConfig } from "@server/db/schemas";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z.object({}).strict();
const bodySchema = z
.object({
name: z.string().nonempty(),
clientId: z.string().nonempty(),
clientSecret: z.string().nonempty(),
authUrl: z.string().url(),
tokenUrl: z.string().url(),
identifierPath: z.string().nonempty(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().nonempty(),
autoProvision: z.boolean().optional()
})
.strict();
export type CreateIdpResponse = {
idpId: number;
redirectUrl: string;
};
registry.registerPath({
method: "put",
path: "/idp/oidc",
description: "Create an OIDC IdP.",
tags: [OpenAPITags.Idp],
request: {
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createOidcIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const {
clientId,
clientSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
name,
autoProvision
} = parsedBody.data;
const key = config.getRawConfig().server.secret;
const encryptedSecret = encrypt(clientSecret, key);
const encryptedClientId = encrypt(clientId, key);
let idpId: number | undefined;
await db.transaction(async (trx) => {
const [idpRes] = await trx
.insert(idp)
.values({
name,
autoProvision,
type: "oidc"
})
.returning();
idpId = idpRes.idpId;
await trx.insert(idpOidcConfig).values({
idpId: idpRes.idpId,
clientId: encryptedClientId,
clientSecret: encryptedSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath
});
});
const redirectUrl = generateOidcRedirectUrl(idpId as number);
return response<CreateIdpResponse>(res, {
data: {
idpId: idpId as number,
redirectUrl
},
success: true,
error: false,
message: "Idp created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,89 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
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 { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
registry.registerPath({
method: "delete",
path: "/idp/{idpId}",
description: "Delete IDP.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
// Check if IDP exists
const [existingIdp] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId));
if (!existingIdp) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"IdP not found"
)
);
}
// Delete the IDP and its related records in a transaction
await db.transaction(async (trx) => {
// Delete OIDC config if it exists
await trx
.delete(idpOidcConfig)
.where(eq(idpOidcConfig.idpId, idpId));
// Delete the IDP itself
await trx
.delete(idp)
.where(eq(idp.idpId, idpId));
});
return response<null>(res, {
data: null,
success: true,
error: false,
message: "IdP deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,148 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
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 { idp, idpOidcConfig } from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import cookie from "cookie";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
redirectUrl: z.string()
})
.strict();
export type GenerateOidcUrlResponse = {
redirectUrl: string;
};
export async function generateOidcUrl(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { redirectUrl: postAuthRedirectUrl } = parsedBody.data;
const [existingIdp] = await db
.select()
.from(idp)
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
if (!existingIdp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP not found for the organization"
)
);
}
const parsedScopes = existingIdp.idpOidcConfig.scopes
.split(" ")
.map((scope) => {
return scope.trim();
})
.filter((scope) => {
return scope.length > 0;
});
const key = config.getRawConfig().server.secret;
const decryptedClientId = decrypt(
existingIdp.idpOidcConfig.clientId,
key
);
const decryptedClientSecret = decrypt(
existingIdp.idpOidcConfig.clientSecret,
key
);
const redirectUrl = generateOidcRedirectUrl(idpId);
const client = new arctic.OAuth2Client(
decryptedClientId,
decryptedClientSecret,
redirectUrl
);
const codeVerifier = arctic.generateCodeVerifier();
const state = arctic.generateState();
const url = client.createAuthorizationURLWithPKCE(
existingIdp.idpOidcConfig.authUrl,
state,
arctic.CodeChallengeMethod.S256,
codeVerifier,
parsedScopes
);
const stateJwt = jsonwebtoken.sign(
{
redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe
state,
codeVerifier
},
config.getRawConfig().server.secret
);
res.cookie("p_oidc_state", stateJwt, {
path: "/",
httpOnly: true,
secure: req.protocol === "https",
expires: new Date(Date.now() + 60 * 10 * 1000),
sameSite: "lax"
});
return response<GenerateOidcUrlResponse>(res, {
data: {
redirectUrl: url.toString()
},
success: true,
error: false,
message: "Idp auth url generated",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,97 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
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 config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
async function query(idpId: number) {
const [res] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.limit(1);
return res;
}
export type GetIdpResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
registry.registerPath({
method: "get",
path: "/idp/{idpId}",
description: "Get an IDP by its IDP ID.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema
},
responses: {}
});
export async function getIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const idpRes = await query(idpId);
if (!idpRes) {
return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found"));
}
const key = config.getRawConfig().server.secret;
if (idpRes.idp.type === "oidc") {
const clientSecret = idpRes.idpOidcConfig!.clientSecret;
const clientId = idpRes.idpOidcConfig!.clientId;
idpRes.idpOidcConfig!.clientSecret = decrypt(
clientSecret,
key
);
idpRes.idpOidcConfig!.clientId = decrypt(
clientId,
key
);
}
return response<GetIdpResponse>(res, {
data: idpRes,
success: true,
error: false,
message: "Idp retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,7 @@
export * from "./createOidcIdp";
export * from "./updateOidcIdp";
export * from "./deleteIdp";
export * from "./listIdps";
export * from "./generateOidcUrl";
export * from "./validateOidcCallback";
export * from "./getIdp";

View file

@ -0,0 +1,111 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idp } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const querySchema = z
.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().nonnegative()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
})
.strict();
async function query(limit: number, offset: number) {
const res = await db
.select({
idpId: idp.idpId,
name: idp.name,
type: idp.type,
})
.from(idp)
.groupBy(idp.idpId)
.limit(limit)
.offset(offset);
return res;
}
export type ListIdpsResponse = {
idps: Array<{
idpId: number;
name: string;
type: string;
}>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
registry.registerPath({
method: "get",
path: "/idp",
description: "List all IDP in the system.",
tags: [OpenAPITags.Idp],
request: {
query: querySchema
},
responses: {}
});
export async function listIdps(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const list = await query(limit, offset);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(idp);
return response<ListIdpsResponse>(res, {
data: {
idps: list,
pagination: {
total: count,
limit,
offset
}
},
success: true,
error: false,
message: "Idps retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,184 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
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 { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
name: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
authUrl: z.string().optional(),
tokenUrl: z.string().optional(),
identifierPath: z.string().optional(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().optional(),
autoProvision: z.boolean().optional(),
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional()
})
.strict();
export type UpdateIdpResponse = {
idpId: number;
};
registry.registerPath({
method: "post",
path: "/idp/:idpId/oidc",
description: "Update an OIDC IdP.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateOidcIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const {
clientId,
clientSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
name,
autoProvision,
defaultRoleMapping,
defaultOrgMapping
} = parsedBody.data;
// Check if IDP exists and is of type OIDC
const [existingIdp] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId));
if (!existingIdp) {
return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found"));
}
if (existingIdp.type !== "oidc") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP is not an OIDC provider"
)
);
}
const key = config.getRawConfig().server.secret;
const encryptedSecret = clientSecret
? encrypt(clientSecret, key)
: undefined;
const encryptedClientId = clientId ? encrypt(clientId, key) : undefined;
await db.transaction(async (trx) => {
const idpData = {
name,
autoProvision,
defaultRoleMapping,
defaultOrgMapping
};
// only update if at least one key is not undefined
let keysToUpdate = Object.keys(idpData).filter(
(key) => idpData[key as keyof typeof idpData] !== undefined
);
if (keysToUpdate.length > 0) {
await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId));
}
const configData = {
clientId: encryptedClientId,
clientSecret: encryptedSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath
};
keysToUpdate = Object.keys(configData).filter(
(key) =>
configData[key as keyof typeof configData] !== undefined
);
if (keysToUpdate.length > 0) {
// Update OIDC config
await trx
.update(idpOidcConfig)
.set(configData)
.where(eq(idpOidcConfig.idpId, idpId));
}
});
return response<UpdateIdpResponse>(res, {
data: {
idpId
},
success: true,
error: false,
message: "IdP updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,250 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
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 { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
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({
idpId: z.coerce.number()
})
.strict();
const bodySchema = z.object({
code: z.string().nonempty(),
state: z.string().nonempty(),
storedState: z.string().nonempty()
});
export type ValidateOidcUrlCallbackResponse = {
redirectUrl: string;
};
export async function validateOidcCallback(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { storedState, code, state: expectedState } = parsedBody.data;
const [existingIdp] = await db
.select()
.from(idp)
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
if (!existingIdp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP not found for the organization"
)
);
}
const key = config.getRawConfig().server.secret;
const decryptedClientId = decrypt(
existingIdp.idpOidcConfig.clientId,
key
);
const decryptedClientSecret = decrypt(
existingIdp.idpOidcConfig.clientSecret,
key
);
const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId);
const client = new arctic.OAuth2Client(
decryptedClientId,
decryptedClientSecret,
redirectUrl
);
const statePayload = jsonwebtoken.verify(
storedState,
config.getRawConfig().server.secret,
function (err, decoded) {
if (err) {
logger.error("Error verifying state JWT", { err });
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid state JWT"
)
);
}
return decoded;
}
);
const stateObj = z
.object({
redirectUrl: z.string(),
state: z.string(),
codeVerifier: z.string()
})
.safeParse(statePayload);
if (!stateObj.success) {
logger.error("Error parsing state JWT");
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(stateObj.error).toString()
)
);
}
const {
codeVerifier,
state,
redirectUrl: postAuthRedirectUrl
} = stateObj.data;
if (state !== expectedState) {
logger.error("State mismatch", { expectedState, state });
return next(
createHttpError(HttpCode.BAD_REQUEST, "State mismatch")
);
}
const tokens = await client.validateAuthorizationCode(
existingIdp.idpOidcConfig.tokenUrl,
code,
codeVerifier
);
const idToken = tokens.idToken();
const claims = arctic.decodeIdToken(idToken);
const userIdentifier = jmespath.search(
claims,
existingIdp.idpOidcConfig.identifierPath
);
if (!userIdentifier) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User identifier not found in the ID token"
)
);
}
logger.debug("User identifier", { userIdentifier });
let email = null;
let name = null;
try {
if (existingIdp.idpOidcConfig.emailPath) {
email = jmespath.search(
claims,
existingIdp.idpOidcConfig.emailPath
);
}
if (existingIdp.idpOidcConfig.namePath) {
name = jmespath.search(
claims,
existingIdp.idpOidcConfig.namePath || ""
);
}
} catch (error) {}
logger.debug("User email", { email });
logger.debug("User name", { name });
const [existingUser] = await db
.select()
.from(users)
.where(
and(
eq(users.username, userIdentifier),
eq(users.idpId, existingIdp.idp.idpId)
)
);
if (existingIdp.idp.autoProvision) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Auto provisioning is not supported"
)
);
} else {
if (!existingUser) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"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<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
},
success: true,
error: false,
message: "OIDC callback validated successfully",
status: HttpCode.CREATED
});
}
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -2,6 +2,7 @@ export * from "./getOrg";
export * from "./createOrg";
export * from "./deleteOrg";
export * from "./updateOrg";
export * from "./listOrgs";
export * from "./listUserOrgs";
export * from "./checkId";
export * from "./getOrgOverview";
export * from "./listOrgs";

View file

@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { Org, orgs } from "@server/db/schemas";
import { Org, orgs, userOrgs } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, inArray } from "drizzle-orm";
import { sql, inArray, eq } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@ -27,8 +27,8 @@ const listOrgsSchema = z.object({
registry.registerPath({
method: "get",
path: "/orgs",
description: "List all organizations in the system",
path: "/user/:userId/orgs",
description: "List all organizations in the system.",
tags: [OpenAPITags.Org],
request: {
query: listOrgsSchema
@ -59,37 +59,15 @@ export async function listOrgs(
const { limit, offset } = parsedQuery.data;
// Use the userOrgs passed from the middleware
const userOrgIds = req.userOrgIds;
if (!userOrgIds || userOrgIds.length === 0) {
return response<ListOrgsResponse>(res, {
data: {
orgs: [],
pagination: {
total: 0,
limit,
offset
}
},
success: true,
error: false,
message: "No organizations found for the user",
status: HttpCode.OK
});
}
const organizations = await db
.select()
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds))
.limit(limit)
.offset(offset);
const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds));
.from(orgs);
const totalCount = totalCountResult[0].count;
return response<ListOrgsResponse>(res, {

View file

@ -0,0 +1,141 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { Org, orgs, userOrgs } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, inArray, eq } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listOrgsParamsSchema = z.object({
userId: z.string()
});
const listOrgsSchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
registry.registerPath({
method: "get",
path: "/user/:userId/orgs",
description: "List all organizations for a user.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
query: listOrgsSchema
},
responses: {}
});
export type ListUserOrgsResponse = {
orgs: Org[];
pagination: { total: number; limit: number; offset: number };
};
export async function listUserOrgs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listOrgsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedQuery.error)
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listOrgsParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { userId } = parsedParams.data;
const userOrganizations = await db
.select({
orgId: userOrgs.orgId,
roleId: userOrgs.roleId
})
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
const userOrgIds = userOrganizations.map((org) => org.orgId);
if (!userOrgIds || userOrgIds.length === 0) {
return response<ListUserOrgsResponse>(res, {
data: {
orgs: [],
pagination: {
total: 0,
limit,
offset
}
},
success: true,
error: false,
message: "No organizations found for the user",
status: HttpCode.OK
});
}
const organizations = await db
.select()
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds))
.limit(limit)
.offset(offset);
const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds));
const totalCount = totalCountResult[0].count;
return response<ListUserOrgsResponse>(res, {
data: {
orgs: organizations,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Organizations retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}

View file

@ -39,7 +39,6 @@ const createHttpResourceSchema = z
isBaseDomain: z.boolean().optional(),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
domainId: z.string()
})
.strict()
@ -203,7 +202,7 @@ async function createHttpResource(
);
}
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
const { name, subdomain, isBaseDomain, http, domainId } =
parsedBody.data;
const [orgDomain] = await db
@ -262,7 +261,7 @@ async function createHttpResource(
name,
subdomain,
http,
protocol,
protocol: "tcp",
ssl: true,
isBaseDomain
})

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
import { idp, userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -23,10 +23,15 @@ async function queryUsers(resourceId: number) {
return await db
.select({
userId: userResources.userId,
username: users.username,
type: users.type,
idpName: idp.name,
idpId: users.idpId,
email: users.email
})
.from(userResources)
.innerJoin(users, eq(userResources.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userResources.resourceId, resourceId));
}

View file

@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq } from "drizzle-orm";
import logger from "@server/logger";
import { users } from "@server/db/schemas";
import { idp, users } from "@server/db/schemas";
import { fromZodError } from "zod-validation-error";
const listUsersSchema = z
@ -31,10 +31,16 @@ async function queryUsers(limit: number, offset: number) {
.select({
id: users.userId,
email: users.email,
username: users.username,
name: users.name,
dateCreated: users.dateCreated,
serverAdmin: users.serverAdmin
serverAdmin: users.serverAdmin,
type: users.type,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(users.serverAdmin, false))
.limit(limit)
.offset(offset);

View file

@ -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<any> {
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<CreateOrgUserResponse>(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")
);
}
}

View file

@ -17,6 +17,9 @@ async function queryUser(orgId: string, userId: string) {
orgId: userOrgs.orgId,
userId: users.userId,
email: users.email,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { users } from "@server/db/schemas";
import { idp, users } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -13,11 +13,17 @@ async function queryUser(userId: string) {
.select({
userId: users.userId,
email: users.email,
username: users.username,
name: users.name,
type: users.type,
twoFactorEnabled: users.twoFactorEnabled,
emailVerified: users.emailVerified,
serverAdmin: users.serverAdmin
serverAdmin: users.serverAdmin,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(users.userId, userId))
.limit(1);
return user;

View file

@ -9,3 +9,4 @@ export * from "./adminListUsers";
export * from "./adminRemoveUser";
export * from "./listInvitations";
export * from "./removeInvitation";
export * from "./createOrgUser";

View file

@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails";
import SendInviteLink from "@server/emails/templates/SendInviteLink";
import { OpenAPITags, registry } from "@server/openApi";
import { UserType } from "@server/types/UserTypes";
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
@ -115,7 +116,13 @@ export async function inviteUser(
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
.where(
and(
eq(users.email, email),
eq(userOrgs.orgId, orgId),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser.length) {
@ -190,7 +197,7 @@ export async function inviteUser(
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: org[0].name || orgId,
inviterName: req.user?.email
inviterName: req.user?.email || req.user?.username
}),
{
to: email,
@ -242,7 +249,7 @@ export async function inviteUser(
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: org[0].name || orgId,
inviterName: req.user?.email
inviterName: req.user?.email || req.user?.username
}),
{
to: email,

View file

@ -1,14 +1,15 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roles, userOrgs, users } from "@server/db/schemas";
import { idp, roles, userOrgs, users } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import { and, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const listUsersParamsSchema = z
.object({
@ -41,14 +42,20 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
emailVerified: users.emailVerified,
dateCreated: users.dateCreated,
orgId: userOrgs.orgId,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner
isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`)
.leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`)
.where(sql`${userOrgs.orgId} = ${orgId}`)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId))
.limit(limit)
.offset(offset);
}
@ -107,7 +114,8 @@ export async function listUsers(
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(users);
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
return response<ListUsersResponse>(res, {
data: {

View file

@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
import moment from "moment";
import { fromError } from "zod-validation-error";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export async function setupServerAdmin() {
const {
@ -34,7 +35,7 @@ export async function setupServerAdmin() {
if (existing) {
const passwordChanged = !(await verifyPassword(
password,
existing.passwordHash
existing.passwordHash!
));
if (passwordChanged) {
@ -65,6 +66,8 @@ export async function setupServerAdmin() {
await db.insert(users).values({
userId: userId,
email: email,
type: UserType.Internal,
username: email,
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,

View file

@ -0,0 +1,4 @@
export enum UserType {
Internal = "internal",
OIDC = "oidc"
}