mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-24 04:34:55 +02:00
Merge branch 'auth-providers' into dev
This commit is contained in:
commit
f4fd33b47f
93 changed files with 5788 additions and 1608 deletions
|
@ -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(
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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
40
server/lib/crypto.ts
Normal 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");
|
||||
}
|
8
server/lib/idp/generateRedirectUrl.ts
Normal file
8
server/lib/idp/generateRedirectUrl.ts
Normal 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;
|
||||
}
|
|
@ -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";
|
||||
|
|
44
server/middlewares/verifyIsLoggedInUser.ts
Normal file
44
server/middlewares/verifyIsLoggedInUser.ts
Normal 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -11,5 +11,6 @@ export enum OpenAPITags {
|
|||
Invitation = "Invitation",
|
||||
Target = "Target",
|
||||
Rule = "Rule",
|
||||
AccessToken = "Access Token"
|
||||
AccessToken = "Access Token",
|
||||
Idp = "Identity Provider"
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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()
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
132
server/routers/idp/createOidcIdp.ts
Normal file
132
server/routers/idp/createOidcIdp.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
89
server/routers/idp/deleteIdp.ts
Normal file
89
server/routers/idp/deleteIdp.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
148
server/routers/idp/generateOidcUrl.ts
Normal file
148
server/routers/idp/generateOidcUrl.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
97
server/routers/idp/getIdp.ts
Normal file
97
server/routers/idp/getIdp.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
7
server/routers/idp/index.ts
Normal file
7
server/routers/idp/index.ts
Normal 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";
|
111
server/routers/idp/listIdps.ts
Normal file
111
server/routers/idp/listIdps.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
184
server/routers/idp/updateOidcIdp.ts
Normal file
184
server/routers/idp/updateOidcIdp.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
250
server/routers/idp/validateOidcCallback.ts
Normal file
250
server/routers/idp/validateOidcCallback.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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, {
|
||||
|
|
141
server/routers/org/listUserOrgs.ts
Normal file
141
server/routers/org/listUserOrgs.ts
Normal 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..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
207
server/routers/user/createOrgUser.ts
Normal file
207
server/routers/user/createOrgUser.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -9,3 +9,4 @@ export * from "./adminListUsers";
|
|||
export * from "./adminRemoveUser";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
4
server/types/UserTypes.ts
Normal file
4
server/types/UserTypes.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export enum UserType {
|
||||
Internal = "internal",
|
||||
OIDC = "oidc"
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue