From 480a5f648db37b252a9c482b4e3325090281362b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 12 Apr 2025 15:39:15 -0400 Subject: [PATCH 01/26] testing oidc callback --- package-lock.json | 110 +++++++- package.json | 5 + server/auth/actions.ts | 3 +- server/auth/sessions/orgIdp.ts | 126 +++++++++ server/db/schemas/schema.ts | 82 ++++++ server/lib/idp/generateRedirectUrl.ts | 8 + server/openApi.ts | 3 +- server/routers/external.ts | 23 +- server/routers/idp/createOidcIdp.ts | 158 +++++++++++ server/routers/idp/generateOidcUrl.ts | 116 ++++++++ server/routers/idp/index.ts | 3 + server/routers/idp/validateOidcCallback.ts | 250 ++++++++++++++++++ src/app/[orgId]/settings/sites/SitesTable.tsx | 12 + .../oidc/callback/ValidateOidcToken.tsx | 75 ++++++ .../idp/[idpId]/oidc/callback/page.tsx | 30 +++ 15 files changed, 997 insertions(+), 7 deletions(-) create mode 100644 server/auth/sessions/orgIdp.ts create mode 100644 server/lib/idp/generateRedirectUrl.ts create mode 100644 server/routers/idp/createOidcIdp.ts create mode 100644 server/routers/idp/generateOidcUrl.ts create mode 100644 server/routers/idp/index.ts create mode 100644 server/routers/idp/validateOidcCallback.ts create mode 100644 src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx create mode 100644 src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx diff --git a/package-lock.json b/package-lock.json index 74c1d2f9..a5e0e0fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,13 +33,16 @@ "@react-email/render": "^1.0.6", "@react-email/tailwind": "1.0.4", "@tanstack/react-table": "8.20.6", + "arctic": "^3.6.0", "axios": "1.8.4", "better-sqlite3": "11.7.0", "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.0.4", + "cookie": "^1.0.2", "cookie-parser": "1.4.7", + "cookies": "^0.9.1", "cors": "2.8.5", "drizzle-orm": "0.38.3", "eslint": "9.17.0", @@ -51,6 +54,7 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.1", + "jmespath": "^0.16.0", "js-yaml": "4.1.0", "lucide-react": "0.469.0", "moment": "2.30.1", @@ -86,6 +90,7 @@ "@types/cookie-parser": "1.4.8", "@types/cors": "2.8.17", "@types/express": "5.0.0", + "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/node": "^22", "@types/nodemailer": "6.4.17", @@ -2749,6 +2754,21 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@oslojs/jwt": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz", + "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", + "license": "MIT", + "dependencies": { + "@oslojs/encoding": "0.4.1" + } + }, + "node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", + "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", + "license": "MIT" + }, "node_modules/@petamoriken/float16": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", @@ -4173,6 +4193,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jmespath": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", + "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -4857,6 +4884,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/arctic": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/arctic/-/arctic-3.6.0.tgz", + "integrity": "sha512-egHDsCqEacb6oSHz5QSSxNhp07J+QJwJdPvs0katL+mNM5LaGQVqxmcdq1KwfaSNSAlVumBBs0MRExS88TxbMg==", + "license": "MIT", + "dependencies": { + "@oslojs/crypto": "1.0.1", + "@oslojs/encoding": "1.1.0", + "@oslojs/jwt": "0.2.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5746,12 +5784,12 @@ } }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-parser": { @@ -5767,12 +5805,34 @@ "node": ">= 0.8.0" } }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -6857,6 +6917,16 @@ "node": ">=10.0.0" } }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/engine.io/node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -9074,6 +9144,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=16" @@ -9120,6 +9191,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9196,6 +9276,18 @@ "node": ">=4.0" } }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -15778,6 +15870,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsx": { "version": "4.19.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", @@ -16103,6 +16204,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^3.1.1" diff --git a/package.json b/package.json index 0eba4e31..e715a29b 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,16 @@ "@react-email/render": "^1.0.6", "@react-email/tailwind": "1.0.4", "@tanstack/react-table": "8.20.6", + "arctic": "^3.6.0", "axios": "1.8.4", "better-sqlite3": "11.7.0", "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.0.4", + "cookie": "^1.0.2", "cookie-parser": "1.4.7", + "cookies": "^0.9.1", "cors": "2.8.5", "drizzle-orm": "0.38.3", "eslint": "9.17.0", @@ -62,6 +65,7 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.1", + "jmespath": "^0.16.0", "js-yaml": "4.1.0", "lucide-react": "0.469.0", "moment": "2.30.1", @@ -97,6 +101,7 @@ "@types/cookie-parser": "1.4.8", "@types/cors": "2.8.17", "@types/express": "5.0.0", + "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/node": "^22", "@types/nodemailer": "6.4.17", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 009d5c21..59492017 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -65,7 +65,8 @@ export enum ActionsEnum { listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", listOrgDomains = "listOrgDomains", - createNewt = "createNewt" + createNewt = "createNewt", + createIdp = "createIdp" } export async function checkUserActionPermission( diff --git a/server/auth/sessions/orgIdp.ts b/server/auth/sessions/orgIdp.ts new file mode 100644 index 00000000..a8e4332d --- /dev/null +++ b/server/auth/sessions/orgIdp.ts @@ -0,0 +1,126 @@ +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { + IdpSession, + idpSessions, + IdpUser, + idpUser, + resourceSessions +} from "@server/db/schemas"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import cookie from "cookie"; + +const SESSION_COOKIE_EXPIRES = + 1000 * + 60 * + 60 * + config.getRawConfig().server.dashboard_session_length_hours; +const COOKIE_DOMAIN = + "." + new URL(config.getRawConfig().app.dashboard_url).hostname; + +export async function createIdpSession( + token: string, + idpUserId: string +): Promise { + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)) + ); + const session: IdpSession = { + idpSessionId: sessionId, + idpUserId, + expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime() + }; + await db.insert(idpSessions).values(session); + return session; +} + +export async function validateIdpSessionToken( + token: string +): Promise { + const idpSessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)) + ); + const result = await db + .select({ idpUser: idpUser, idpSession: idpSessions }) + .from(idpSessions) + .innerJoin(idpUser, eq(idpSessions.idpUserId, idpUser.idpUserId)) + .where(eq(idpSessions.idpSessionId, idpSessionId)); + if (result.length < 1) { + return { session: null, user: null }; + } + const { idpUser: idpUserRes, idpSession: idpSessionRes } = result[0]; + if (Date.now() >= idpSessionRes.expiresAt) { + await db + .delete(idpSessions) + .where(eq(idpSessions.idpSessionId, idpSessionRes.idpSessionId)); + return { session: null, user: null }; + } + if (Date.now() >= idpSessionRes.expiresAt - SESSION_COOKIE_EXPIRES / 2) { + idpSessionRes.expiresAt = new Date( + Date.now() + SESSION_COOKIE_EXPIRES + ).getTime(); + await db.transaction(async (trx) => { + await trx + .update(idpSessions) + .set({ + expiresAt: idpSessionRes.expiresAt + }) + .where( + eq(idpSessions.idpSessionId, idpSessionRes.idpSessionId) + ); + + await trx + .update(resourceSessions) + .set({ + expiresAt: idpSessionRes.expiresAt + }) + .where( + eq( + resourceSessions.idpSessionId, + idpSessionRes.idpSessionId + ) + ); + }); + } + return { session: idpSessionRes, user: idpUserRes }; +} + +export async function invalidateIdpSession( + idpSessionId: string +): Promise { + try { + await db.transaction(async (trx) => { + await trx + .delete(resourceSessions) + .where(eq(resourceSessions.idpSessionId, idpSessionId)); + await trx + .delete(idpSessions) + .where(eq(idpSessions.idpSessionId, idpSessionId)); + }); + } catch (e) { + logger.error("Failed to invalidate session", e); + } +} + +export function serializeIdpSessionCookie( + cookieName: string, + token: string, + isSecure: boolean, + expiresAt: Date +): string { + return cookie.serialize(cookieName, token, { + httpOnly: true, + sameSite: "lax", + expires: expiresAt, + path: "/", + secure: isSecure, + domain: COOKIE_DOMAIN + }); +} + +export type IdpSessionValidationResult = + | { session: IdpSession; user: IdpUser } + | { session: null; user: null }; diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index a8627553..65c8dc31 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -340,6 +340,12 @@ export const resourceSessions = sqliteTable("resourceSessions", { .notNull() .default(false), isRequestToken: integer("isRequestToken", { mode: "boolean" }), + idpSessionId: text("idpSessionId").references( + () => idpSessions.idpSessionId, + { + onDelete: "cascade" + } + ), userSessionId: text("userSessionId").references(() => sessions.sessionId, { onDelete: "cascade" }), @@ -415,6 +421,77 @@ 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 }), + type: text("type").notNull() +}); + +// 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(), + autoProvision: integer("autoProvision", { + mode: "boolean" + }) + .notNull() + .default(false), + identifierPath: text("identifierPath").notNull(), + emailPath: text("emailPath"), // by default, this is "email" + namePath: text("namePath"), // by default, this is "name" + roleMapping: text("roleMapping"), + scopes: text("scopes").notNull() +}); + +export const idpOrg = sqliteTable("idpOrg", { + idpId: integer("idpId") + .notNull() + .references(() => idp.idpId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + +// IDP User +export const idpUser = sqliteTable("idpUser", { + idpUserId: text("idpUserId").primaryKey(), + identifier: text("identifier").notNull(), + idpId: integer("idpId") + .notNull() + .references(() => idp.idpId, { onDelete: "cascade" }), + email: text("email"), + name: text("name") +}); + +// IDP User Organization Link +export const idpUserOrg = sqliteTable("idpUserOrg", { + idpUserId: text("idpUserId") + .notNull() + .references(() => idpUser.idpUserId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) +}); + +export const idpSessions = sqliteTable("idpSessions", { + idpSessionId: text("idpSessionId").primaryKey(), + idpUserId: text("idpUserId") + .notNull() + .references(() => idpUser.idpUserId, { onDelete: "cascade" }), + expiresAt: integer("expiresAt").notNull() +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -450,3 +527,8 @@ export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type SupporterKey = InferSelectModel; +export type Idp = InferSelectModel; +export type IdpUser = InferSelectModel; +export type IdpOrg = InferSelectModel; +export type IdpUserOrg = InferSelectModel; +export type IdpSession = InferSelectModel; diff --git a/server/lib/idp/generateRedirectUrl.ts b/server/lib/idp/generateRedirectUrl.ts new file mode 100644 index 00000000..220c6057 --- /dev/null +++ b/server/lib/idp/generateRedirectUrl.ts @@ -0,0 +1,8 @@ +import config from "@server/lib/config"; + +export function generateOidcRedirectUrl(orgId: string, idpId: number) { + const dashboardUrl = config.getRawConfig().app.dashboard_url; + const redirectPath = `/auth/org/${orgId}/idp/${idpId}/oidc/callback`; + const redirectUrl = new URL(redirectPath, dashboardUrl).toString(); + return redirectUrl; +} diff --git a/server/openApi.ts b/server/openApi.ts index 8a02e886..43e84e56 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -11,5 +11,6 @@ export enum OpenAPITags { Invitation = "Invitation", Target = "Target", Rule = "Rule", - AccessToken = "Access Token" + AccessToken = "Access Token", + Idp = "Identity Provider" } diff --git a/server/routers/external.ts b/server/routers/external.ts index 9c747e01..09fd10a4 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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, @@ -493,6 +494,13 @@ authenticated.delete( // createNewt // ); +authenticated.put( + "/org/:orgId/idp/oidc", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createIdp), + idp.createOidcIdp +) + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); @@ -581,4 +589,17 @@ authRouter.post( resource.authWithAccessToken ); -authRouter.post("/access-token", resource.authWithAccessToken); +authRouter.post( + "/access-token", + resource.authWithAccessToken +); + +authRouter.post( + "/org/:orgId/idp/:idpId/oidc/generate-url", + idp.generateOidcUrl +) + +authRouter.post( + "/org/:orgId/idp/:idpId/oidc/validate-callback", + idp.validateOidcCallback +) diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts new file mode 100644 index 00000000..44f6a6bb --- /dev/null +++ b/server/routers/idp/createOidcIdp.ts @@ -0,0 +1,158 @@ +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, idpOrg, orgs } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import { generateOidcUrl } from "./generateOidcUrl"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; + +const paramsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + clientId: z.string().nonempty(), + clientSecret: z.string().nonempty(), + authUrl: z.string().url(), + tokenUrl: z.string().url(), + autoProvision: z.boolean(), + identifierPath: z.string().nonempty(), + emailPath: z.string().optional(), + namePath: z.string().optional(), + roleMapping: z.string().optional(), + scopes: z.array(z.string().nonempty()) + }) + .strict(); + +export type CreateIdpResponse = { + idpId: number; + redirectUrl: string; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/idp/oidc", + description: "Create an OIDC IdP for an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createOidcIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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 { orgId } = parsedParams.data; + + const { + clientId, + clientSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath, + roleMapping, + autoProvision + } = parsedBody.data; + + // Check if the org exists + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + let idpId: number | undefined; + await db.transaction(async (trx) => { + const [idpRes] = await trx + .insert(idp) + .values({ + type: "oidc" + }) + .returning(); + + idpId = idpRes.idpId; + + await trx.insert(idpOidcConfig).values({ + idpId: idpRes.idpId, + clientId, + clientSecret, + authUrl, + tokenUrl, + autoProvision, + scopes: JSON.stringify(scopes), + identifierPath, + emailPath, + namePath, + roleMapping + }); + + await trx.insert(idpOrg).values({ + idpId: idpRes.idpId, + orgId + }); + }); + + const redirectUrl = generateOidcRedirectUrl(orgId, idpId as number); + + return response(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") + ); + } +} diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts new file mode 100644 index 00000000..4b68f07c --- /dev/null +++ b/server/routers/idp/generateOidcUrl.ts @@ -0,0 +1,116 @@ +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, idpOrg } 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"; + +const paramsSchema = z + .object({ + orgId: z.string(), + idpId: z.coerce.number() + }) + .strict(); + +export type GenerateOidcUrlResponse = { + redirectUrl: string; +}; + +export async function generateOidcUrl( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, idpId } = parsedParams.data; + + const [existingIdp] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .where( + and( + eq(idpOrg.orgId, orgId), + eq(idp.type, "oidc"), + eq(idp.idpId, idpId) + ) + ); + + if (!existingIdp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP not found for the organization" + ) + ); + } + + const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes); + + const redirectUrl = generateOidcRedirectUrl(orgId, idpId); + const client = new arctic.OAuth2Client( + existingIdp.idpOidcConfig.clientId, + existingIdp.idpOidcConfig.clientSecret, + redirectUrl + ); + + const codeVerifier = arctic.generateCodeVerifier(); + const state = arctic.generateState(); + const url = client.createAuthorizationURLWithPKCE( + existingIdp.idpOidcConfig.authUrl, + state, + arctic.CodeChallengeMethod.S256, + codeVerifier, + parsedScopes + ); + + res.cookie("oidc_state", state, { + path: "/", + httpOnly: true, + secure: req.protocol === "https", + expires: new Date(Date.now() + 60 * 10 * 1000), + sameSite: "lax" + }); + + res.cookie(`oidc_code_verifier`, codeVerifier, { + path: "/", + httpOnly: true, + secure: req.protocol === "https", + expires: new Date(Date.now() + 60 * 10 * 1000), + sameSite: "lax" + }); + + return response(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") + ); + } +} diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts new file mode 100644 index 00000000..70fbbcae --- /dev/null +++ b/server/routers/idp/index.ts @@ -0,0 +1,3 @@ +export * from "./createOidcIdp"; +export * from "./generateOidcUrl"; +export * from "./validateOidcCallback"; diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts new file mode 100644 index 00000000..fd61528d --- /dev/null +++ b/server/routers/idp/validateOidcCallback.ts @@ -0,0 +1,250 @@ +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, + idpOrg, + idpUser, + idpUserOrg, + Role, + roles +} 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 { generateId, generateSessionToken } from "@server/auth/sessions/app"; +import { + createIdpSession, + serializeIdpSessionCookie +} from "@server/auth/sessions/orgIdp"; + +const paramsSchema = z + .object({ + orgId: z.string(), + idpId: z.coerce.number() + }) + .strict(); + +const bodySchema = z.object({ + code: z.string().nonempty(), + codeVerifier: z.string().nonempty() +}); + +export type ValidateOidcUrlCallbackResponse = {}; + +export async function validateOidcCallback( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, idpId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { code, codeVerifier } = parsedBody.data; + + const [existingIdp] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .where( + and( + eq(idpOrg.orgId, orgId), + eq(idp.type, "oidc"), + eq(idp.idpId, idpId) + ) + ); + + if (!existingIdp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP not found for the organization" + ) + ); + } + + const redirectUrl = generateOidcRedirectUrl( + orgId, + existingIdp.idp.idpId + ); + const client = new arctic.OAuth2Client( + existingIdp.idpOidcConfig.clientId, + existingIdp.idpOidcConfig.clientSecret, + redirectUrl + ); + + 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 }); + + const email = jmespath.search( + claims, + existingIdp.idpOidcConfig.emailPath || "email" + ); + const name = jmespath.search( + claims, + existingIdp.idpOidcConfig.namePath || "name" + ); + + logger.debug("User email", { email }); + logger.debug("User name", { name }); + + const [existingIdpUser] = await db + .select() + .from(idpUser) + .innerJoin(idpUserOrg, eq(idpUserOrg.idpUserId, idpUser.idpUserId)) + .where( + and( + eq(idpUserOrg.orgId, orgId), + eq(idpUser.idpId, existingIdp.idp.idpId) + ) + ); + + let userRole: Role | undefined; + if (existingIdp.idpOidcConfig.roleMapping) { + const roleName = jmespath.search( + claims, + existingIdp.idpOidcConfig.roleMapping + ); + + if (!roleName) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Role mapping not found in the ID token" + ) + ); + } + + const [roleRes] = await db + .select() + .from(roles) + .where(and(eq(roles.orgId, orgId), eq(roles.name, roleName))); + + userRole = roleRes; + } else { + // TODO: Get the default role for this IDP? + } + + logger.debug("User role", { userRole }); + + if (!userRole) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Role not found for the user" + ) + ); + } + + let userId: string | undefined = existingIdpUser?.idpUser.idpUserId; + if (!existingIdpUser) { + if (existingIdp.idpOidcConfig.autoProvision) { + // TODO: Create the user and automatically assign roles + + await db.transaction(async (trx) => { + const idpUserId = generateId(15); + + const [idpUserRes] = await trx + .insert(idpUser) + .values({ + idpUserId, + idpId: existingIdp.idp.idpId, + identifier: userIdentifier, + email, + name + }) + .returning(); + + await trx.insert(idpUserOrg).values({ + idpUserId: idpUserRes.idpUserId, + orgId, + roleId: userRole.roleId + }); + + userId = idpUserRes.idpUserId; + }); + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User not found and auto-provisioning is disabled" + ) + ); + } + } + + const token = generateSessionToken(); + const sess = await createIdpSession(token, userId); + const cookie = serializeIdpSessionCookie( + `p_idp_${orgId}.${idpId}`, + sess.idpSessionId, + req.protocol === "https", + new Date(sess.expiresAt) + ); + + res.setHeader("Set-Cookie", cookie); + + return response(res, { + data: {}, + 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") + ); + } +} diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index 43ae82a1..2db11b6f 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -279,8 +279,20 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } ]; + async function test() { + const res = await api + .post("/auth/org/home-lab/idp/1/oidc/generate-url") + .then((res) => { + if (res.data.data.redirectUrl) { + window.location.href = res.data.data.redirectUrl; + } + }); + } + return ( <> + + (null); + + useEffect(() => { + if (!props.code || !props.verifier) { + setError("Missing code or verifier"); + setLoading(false); + return; + } + + if (!props.storedState) { + setError("Missing stored state"); + setLoading(false); + return; + } + + if (props.storedState !== props.expectedState) { + setError("Invalid state"); + setLoading(false); + return; + } + + async function validate() { + setLoading(true); + + try { + const res = await api.post< + AxiosResponse + >( + `/auth/org/${props.orgId}/idp/${props.idpId}/oidc/validate-callback`, + { + code: props.code, + codeVerifier: props.verifier + } + ); + } catch (e) { + setError(formatAxiosError(e, "Error validating OIDC token")); + } finally { + setLoading(false); + } + } + + validate(); + }, []); + + return ( + <> +

Validating OIDC Token...

+ {loading &&

Loading...

} + {!loading &&

Token validated successfully!

} + {error &&

Error: {error}

} + + ); +} diff --git a/src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx new file mode 100644 index 00000000..1eb49778 --- /dev/null +++ b/src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx @@ -0,0 +1,30 @@ +import { cookies } from "next/headers"; +import ValidateOidcToken from "./ValidateOidcToken"; + +export default async function Page(props: { + params: Promise<{ orgId: string; idpId: string }>; + searchParams: Promise<{ + code: string; + state: string; + }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + + const allCookies = await cookies(); + const stateCookie = allCookies.get("oidc_state")?.value; + const verifier = allCookies.get("oidc_code_verifier")?.value; + + return ( + <> + + + ); +} From 53be2739bb49c7641a3d787e6ad9bdcf69d575de Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 13 Apr 2025 17:57:27 -0400 Subject: [PATCH 02/26] successful log in loop poc --- package-lock.json | 120 ++++++ package.json | 2 + server/auth/sessions/orgIdp.ts | 126 ------- server/db/schemas/schema.ts | 59 +-- server/lib/config.ts | 3 +- server/lib/idp/generateRedirectUrl.ts | 4 +- server/routers/auth/changePassword.ts | 12 +- server/routers/auth/disable2fa.ts | 16 +- server/routers/auth/login.ts | 9 +- .../auth/requestEmailVerificationCode.ts | 12 +- server/routers/auth/requestPasswordReset.ts | 2 +- server/routers/auth/requestTotpSecret.ts | 14 +- server/routers/auth/signup.ts | 9 +- server/routers/auth/verifyTotp.ts | 14 +- server/routers/external.ts | 10 +- server/routers/idp/createOidcIdp.ts | 49 +-- server/routers/idp/generateOidcUrl.ts | 51 ++- server/routers/idp/validateOidcCallback.ts | 356 ++++++++++++------ server/routers/resource/listResourceUsers.ts | 7 +- server/routers/user/adminListUsers.ts | 10 +- server/routers/user/getOrgUser.ts | 3 + server/routers/user/getUser.ts | 10 +- server/routers/user/inviteUser.ts | 13 +- server/routers/user/listUsers.ts | 17 +- server/setup/setupServerAdmin.ts | 5 +- server/types/UserTypes.ts | 4 + .../settings/access/users/UsersTable.tsx | 42 ++- .../[orgId]/settings/access/users/page.tsx | 6 + .../[resourceId]/authentication/page.tsx | 5 +- src/app/admin/users/AdminUsersTable.tsx | 69 +++- src/app/admin/users/page.tsx | 5 + .../oidc/callback/ValidateOidcToken.tsx | 56 +-- .../idp/[idpId]/oidc/callback/page.tsx | 6 +- src/app/auth/verify-email/page.tsx | 2 +- src/app/navigation.tsx | 8 +- src/components/LoginForm.tsx | 121 +++--- src/components/ProfileIcon.tsx | 6 +- 37 files changed, 789 insertions(+), 474 deletions(-) delete mode 100644 server/auth/sessions/orgIdp.ts create mode 100644 server/types/UserTypes.ts rename src/app/auth/{org/[orgId] => }/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx (57%) rename src/app/auth/{org/[orgId] => }/idp/[idpId]/oidc/callback/page.tsx (76%) diff --git a/package-lock.json b/package-lock.json index f6634dd3..21acb5b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "input-otp": "1.4.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "0.469.0", "moment": "2.30.1", "next": "15.2.4", @@ -93,6 +94,7 @@ "@types/express": "5.0.0", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22", "@types/nodemailer": "6.4.17", "@types/react": "19.1.1", @@ -4464,6 +4466,17 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4471,6 +4484,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", @@ -5590,6 +5610,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7048,6 +7074,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/eciesjs": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz", @@ -9501,6 +9536,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9516,6 +9573,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -9837,12 +9915,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", diff --git a/package.json b/package.json index 4d44e3c6..8aad99c2 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "input-otp": "1.4.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "0.469.0", "moment": "2.30.1", "next": "15.2.4", @@ -104,6 +105,7 @@ "@types/express": "5.0.0", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22", "@types/nodemailer": "6.4.17", "@types/react": "19.1.1", diff --git a/server/auth/sessions/orgIdp.ts b/server/auth/sessions/orgIdp.ts deleted file mode 100644 index a8e4332d..00000000 --- a/server/auth/sessions/orgIdp.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { encodeHexLowerCase } from "@oslojs/encoding"; -import { sha256 } from "@oslojs/crypto/sha2"; -import { - IdpSession, - idpSessions, - IdpUser, - idpUser, - resourceSessions -} from "@server/db/schemas"; -import db from "@server/db"; -import { eq } from "drizzle-orm"; -import logger from "@server/logger"; -import config from "@server/lib/config"; -import cookie from "cookie"; - -const SESSION_COOKIE_EXPIRES = - 1000 * - 60 * - 60 * - config.getRawConfig().server.dashboard_session_length_hours; -const COOKIE_DOMAIN = - "." + new URL(config.getRawConfig().app.dashboard_url).hostname; - -export async function createIdpSession( - token: string, - idpUserId: string -): Promise { - const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)) - ); - const session: IdpSession = { - idpSessionId: sessionId, - idpUserId, - expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime() - }; - await db.insert(idpSessions).values(session); - return session; -} - -export async function validateIdpSessionToken( - token: string -): Promise { - const idpSessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)) - ); - const result = await db - .select({ idpUser: idpUser, idpSession: idpSessions }) - .from(idpSessions) - .innerJoin(idpUser, eq(idpSessions.idpUserId, idpUser.idpUserId)) - .where(eq(idpSessions.idpSessionId, idpSessionId)); - if (result.length < 1) { - return { session: null, user: null }; - } - const { idpUser: idpUserRes, idpSession: idpSessionRes } = result[0]; - if (Date.now() >= idpSessionRes.expiresAt) { - await db - .delete(idpSessions) - .where(eq(idpSessions.idpSessionId, idpSessionRes.idpSessionId)); - return { session: null, user: null }; - } - if (Date.now() >= idpSessionRes.expiresAt - SESSION_COOKIE_EXPIRES / 2) { - idpSessionRes.expiresAt = new Date( - Date.now() + SESSION_COOKIE_EXPIRES - ).getTime(); - await db.transaction(async (trx) => { - await trx - .update(idpSessions) - .set({ - expiresAt: idpSessionRes.expiresAt - }) - .where( - eq(idpSessions.idpSessionId, idpSessionRes.idpSessionId) - ); - - await trx - .update(resourceSessions) - .set({ - expiresAt: idpSessionRes.expiresAt - }) - .where( - eq( - resourceSessions.idpSessionId, - idpSessionRes.idpSessionId - ) - ); - }); - } - return { session: idpSessionRes, user: idpUserRes }; -} - -export async function invalidateIdpSession( - idpSessionId: string -): Promise { - try { - await db.transaction(async (trx) => { - await trx - .delete(resourceSessions) - .where(eq(resourceSessions.idpSessionId, idpSessionId)); - await trx - .delete(idpSessions) - .where(eq(idpSessions.idpSessionId, idpSessionId)); - }); - } catch (e) { - logger.error("Failed to invalidate session", e); - } -} - -export function serializeIdpSessionCookie( - cookieName: string, - token: string, - isSecure: boolean, - expiresAt: Date -): string { - return cookie.serialize(cookieName, token, { - httpOnly: true, - sameSite: "lax", - expires: expiresAt, - path: "/", - secure: isSecure, - domain: COOKIE_DOMAIN - }); -} - -export type IdpSessionValidationResult = - | { session: IdpSession; user: IdpUser } - | { session: null; user: null }; diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index 65c8dc31..207237aa 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -106,8 +106,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), @@ -340,12 +346,6 @@ export const resourceSessions = sqliteTable("resourceSessions", { .notNull() .default(false), isRequestToken: integer("isRequestToken", { mode: "boolean" }), - idpSessionId: text("idpSessionId").references( - () => idpSessions.idpSessionId, - { - onDelete: "cascade" - } - ), userSessionId: text("userSessionId").references(() => sessions.sessionId, { onDelete: "cascade" }), @@ -424,6 +424,7 @@ export const supporterKey = sqliteTable("supporterKey", { // Identity Providers export const idp = sqliteTable("idp", { idpId: integer("idpId").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), type: text("type").notNull() }); @@ -445,9 +446,8 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { .notNull() .default(false), identifierPath: text("identifierPath").notNull(), - emailPath: text("emailPath"), // by default, this is "email" - namePath: text("namePath"), // by default, this is "name" - roleMapping: text("roleMapping"), + emailPath: text("emailPath"), + namePath: text("namePath"), scopes: text("scopes").notNull() }); @@ -455,41 +455,11 @@ export const idpOrg = sqliteTable("idpOrg", { idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), - orgId: text("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) -}); - -// IDP User -export const idpUser = sqliteTable("idpUser", { - idpUserId: text("idpUserId").primaryKey(), - identifier: text("identifier").notNull(), - idpId: integer("idpId") - .notNull() - .references(() => idp.idpId, { onDelete: "cascade" }), - email: text("email"), - name: text("name") -}); - -// IDP User Organization Link -export const idpUserOrg = sqliteTable("idpUserOrg", { - idpUserId: text("idpUserId") - .notNull() - .references(() => idpUser.idpUserId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) -}); - -export const idpSessions = sqliteTable("idpSessions", { - idpSessionId: text("idpSessionId").primaryKey(), - idpUserId: text("idpUserId") - .notNull() - .references(() => idpUser.idpUserId, { onDelete: "cascade" }), - expiresAt: integer("expiresAt").notNull() + roleMapping: text("roleMapping"), + orgMapping: text("orgMapping") }); export type Org = InferSelectModel; @@ -528,7 +498,4 @@ export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; -export type IdpUser = InferSelectModel; export type IdpOrg = InferSelectModel; -export type IdpUserOrg = InferSelectModel; -export type IdpSession = InferSelectModel; diff --git a/server/lib/config.ts b/server/lib/config.ts index f6f4c447..9df1d7a7 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -91,7 +91,8 @@ 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() }), traefik: z.object({ http_entrypoint: z.string(), diff --git a/server/lib/idp/generateRedirectUrl.ts b/server/lib/idp/generateRedirectUrl.ts index 220c6057..4eea973e 100644 --- a/server/lib/idp/generateRedirectUrl.ts +++ b/server/lib/idp/generateRedirectUrl.ts @@ -1,8 +1,8 @@ import config from "@server/lib/config"; -export function generateOidcRedirectUrl(orgId: string, idpId: number) { +export function generateOidcRedirectUrl(idpId: number) { const dashboardUrl = config.getRawConfig().app.dashboard_url; - const redirectPath = `/auth/org/${orgId}/idp/${idpId}/oidc/callback`; + const redirectPath = `/auth/idp/${idpId}/oidc/callback`; const redirectUrl = new URL(redirectPath, dashboardUrl).toString(); return redirectUrl; } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 3be9ef2e..3b1e4c2f 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -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()); diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 45644461..b10dd9b2 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -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" } diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index aa4f0d53..eda637fa 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -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) { diff --git a/server/routers/auth/requestEmailVerificationCode.ts b/server/routers/auth/requestEmailVerificationCode.ts index 47747a95..0cc8825c 100644 --- a/server/routers/auth/requestEmailVerificationCode.ts +++ b/server/routers/auth/requestEmailVerificationCode.ts @@ -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(res, { data: { diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 20a6511a..087352f0 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -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() }); diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index c60904ce..a4f8bc4a 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -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) diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 833850ce..564a1378 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -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() diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index a349d79d..db4ec1a1 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -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" } diff --git a/server/routers/external.ts b/server/routers/external.ts index 09fd10a4..6ad48c26 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -495,9 +495,9 @@ authenticated.delete( // ); authenticated.put( - "/org/:orgId/idp/oidc", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.createIdp), + "/idp/oidc", + verifyUserIsServerAdmin, + // verifyUserHasAction(ActionsEnum.createIdp), idp.createOidcIdp ) @@ -595,11 +595,11 @@ authRouter.post( ); authRouter.post( - "/org/:orgId/idp/:idpId/oidc/generate-url", + "/idp/:idpId/oidc/generate-url", idp.generateOidcUrl ) authRouter.post( - "/org/:orgId/idp/:idpId/oidc/validate-callback", + "/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback ) diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 44f6a6bb..e26064d7 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -8,27 +8,20 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas"; -import { eq } from "drizzle-orm"; -import { generateOidcUrl } from "./generateOidcUrl"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; -const paramsSchema = z - .object({ - orgId: z.string() - }) - .strict(); +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(), - autoProvision: z.boolean(), identifierPath: z.string().nonempty(), emailPath: z.string().optional(), namePath: z.string().optional(), - roleMapping: z.string().optional(), scopes: z.array(z.string().nonempty()) }) .strict(); @@ -44,7 +37,6 @@ registry.registerPath({ description: "Create an OIDC IdP for an organization.", tags: [OpenAPITags.Org, OpenAPITags.Idp], request: { - params: paramsSchema, body: { content: { "application/json": { @@ -62,16 +54,6 @@ export async function createOidcIdp( next: NextFunction ): Promise { 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( @@ -82,8 +64,6 @@ export async function createOidcIdp( ); } - const { orgId } = parsedParams.data; - const { clientId, clientSecret, @@ -93,24 +73,15 @@ export async function createOidcIdp( identifierPath, emailPath, namePath, - roleMapping, - autoProvision + name } = parsedBody.data; - // Check if the org exists - const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); - - if (!org) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Organization not found") - ); - } - let idpId: number | undefined; await db.transaction(async (trx) => { const [idpRes] = await trx .insert(idp) .values({ + name, type: "oidc" }) .returning(); @@ -123,21 +94,15 @@ export async function createOidcIdp( clientSecret, authUrl, tokenUrl, - autoProvision, + autoProvision: true, scopes: JSON.stringify(scopes), identifierPath, emailPath, - namePath, - roleMapping - }); - - await trx.insert(idpOrg).values({ - idpId: idpRes.idpId, - orgId + namePath }); }); - const redirectUrl = generateOidcRedirectUrl(orgId, idpId as number); + const redirectUrl = generateOidcRedirectUrl(idpId as number); return response(res, { data: { diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 4b68f07c..4cb616e4 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -11,14 +11,21 @@ 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"; const paramsSchema = z .object({ - orgId: z.string(), idpId: z.coerce.number() }) .strict(); +const bodySchema = z + .object({ + redirectUrl: z.string() + }) + .strict(); + export type GenerateOidcUrlResponse = { redirectUrl: string; }; @@ -39,20 +46,25 @@ export async function generateOidcUrl( ); } - const { orgId, idpId } = parsedParams.data; + 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(idpOrg, eq(idp.idpId, idpOrg.idpId)) .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) - .where( - and( - eq(idpOrg.orgId, orgId), - eq(idp.type, "oidc"), - eq(idp.idpId, idpId) - ) - ); + .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId))); if (!existingIdp) { return next( @@ -65,7 +77,7 @@ export async function generateOidcUrl( const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes); - const redirectUrl = generateOidcRedirectUrl(orgId, idpId); + const redirectUrl = generateOidcRedirectUrl(idpId); const client = new arctic.OAuth2Client( existingIdp.idpOidcConfig.clientId, existingIdp.idpOidcConfig.clientSecret, @@ -82,15 +94,16 @@ export async function generateOidcUrl( parsedScopes ); - res.cookie("oidc_state", state, { - path: "/", - httpOnly: true, - secure: req.protocol === "https", - expires: new Date(Date.now() + 60 * 10 * 1000), - sameSite: "lax" - }); + const stateJwt = jsonwebtoken.sign( + { + redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe + state, + codeVerifier + }, + config.getRawConfig().server.secret + ); - res.cookie(`oidc_code_verifier`, codeVerifier, { + res.cookie("p_oidc_state", stateJwt, { path: "/", httpOnly: true, secure: req.protocol === "https", diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index fd61528d..232838d1 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -10,34 +10,40 @@ import { idp, idpOidcConfig, idpOrg, - idpUser, - idpUserOrg, Role, - roles + roles, + userOrgs, + users } from "@server/db/schemas"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import * as arctic from "arctic"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import jmespath from "jmespath"; -import { generateId, generateSessionToken } from "@server/auth/sessions/app"; +import jsonwebtoken from "jsonwebtoken"; +import config from "@server/lib/config"; +import { UserType } from "@server/types/UserTypes"; import { - createIdpSession, - serializeIdpSessionCookie -} from "@server/auth/sessions/orgIdp"; + createSession, + generateId, + generateSessionToken, + serializeSessionCookie +} from "@server/auth/sessions/app"; const paramsSchema = z .object({ - orgId: z.string(), idpId: z.coerce.number() }) .strict(); const bodySchema = z.object({ code: z.string().nonempty(), - codeVerifier: z.string().nonempty() + state: z.string().nonempty(), + storedState: z.string().nonempty() }); -export type ValidateOidcUrlCallbackResponse = {}; +export type ValidateOidcUrlCallbackResponse = { + redirectUrl: string; +}; export async function validateOidcCallback( req: Request, @@ -55,7 +61,7 @@ export async function validateOidcCallback( ); } - const { orgId, idpId } = parsedParams.data; + const { idpId } = parsedParams.data; const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { @@ -67,20 +73,13 @@ export async function validateOidcCallback( ); } - const { code, codeVerifier } = parsedBody.data; + const { storedState, code, state: expectedState } = parsedBody.data; const [existingIdp] = await db .select() .from(idp) - .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) - .where( - and( - eq(idpOrg.orgId, orgId), - eq(idp.type, "oidc"), - eq(idp.idpId, idpId) - ) - ); + .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId))); if (!existingIdp) { return next( @@ -91,16 +90,61 @@ export async function validateOidcCallback( ); } - const redirectUrl = generateOidcRedirectUrl( - orgId, - existingIdp.idp.idpId - ); + const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId); const client = new arctic.OAuth2Client( existingIdp.idpOidcConfig.clientId, existingIdp.idpOidcConfig.clientSecret, 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, @@ -126,116 +170,214 @@ export async function validateOidcCallback( logger.debug("User identifier", { userIdentifier }); - const email = jmespath.search( - claims, - existingIdp.idpOidcConfig.emailPath || "email" - ); - const name = jmespath.search( - claims, - existingIdp.idpOidcConfig.namePath || "name" - ); + 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 [existingIdpUser] = await db + const [existingUser] = await db .select() - .from(idpUser) - .innerJoin(idpUserOrg, eq(idpUserOrg.idpUserId, idpUser.idpUserId)) + .from(users) .where( and( - eq(idpUserOrg.orgId, orgId), - eq(idpUser.idpId, existingIdp.idp.idpId) + eq(users.username, userIdentifier), + eq(users.idpId, existingIdp.idp.idpId) ) ); - let userRole: Role | undefined; - if (existingIdp.idpOidcConfig.roleMapping) { - const roleName = jmespath.search( - claims, - existingIdp.idpOidcConfig.roleMapping - ); + const idpOrgs = await db + .select() + .from(idpOrg) + .where(eq(idpOrg.idpId, existingIdp.idp.idpId)); - if (!roleName) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Role mapping not found in the ID token" - ) - ); + let userOrgInfo: { orgId: string; roleId: number }[] = []; + for (const idpOrg of idpOrgs) { + let roleId: number | undefined = undefined; + + if (idpOrg.orgMapping) { + const orgId = jmespath.search(claims, idpOrg.orgMapping); + if (!orgId) { + continue; + } } - const [roleRes] = await db - .select() - .from(roles) - .where(and(eq(roles.orgId, orgId), eq(roles.name, roleName))); + if (idpOrg.roleMapping) { + const roleName = jmespath.search(claims, idpOrg.roleMapping); - userRole = roleRes; - } else { - // TODO: Get the default role for this IDP? - } - - logger.debug("User role", { userRole }); - - if (!userRole) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Role not found for the user" - ) - ); - } - - let userId: string | undefined = existingIdpUser?.idpUser.idpUserId; - if (!existingIdpUser) { - if (existingIdp.idpOidcConfig.autoProvision) { - // TODO: Create the user and automatically assign roles - - await db.transaction(async (trx) => { - const idpUserId = generateId(15); - - const [idpUserRes] = await trx - .insert(idpUser) - .values({ - idpUserId, - idpId: existingIdp.idp.idpId, - identifier: userIdentifier, - email, - name - }) - .returning(); - - await trx.insert(idpUserOrg).values({ - idpUserId: idpUserRes.idpUserId, - orgId, - roleId: userRole.roleId + if (!roleName) { + logger.error("Role name not found in the ID token", { + roleName }); + continue; + } - userId = idpUserRes.idpUserId; + const [roleRes] = await db + .select() + .from(roles) + .where( + and( + eq(roles.orgId, idpOrg.orgId), + eq(roles.name, roleName) + ) + ); + + if (!roleRes) { + logger.error("Role not found", { + orgId: idpOrg.orgId, + roleName + }); + continue; + } + + roleId = roleRes.roleId; + + userOrgInfo.push({ + orgId: idpOrg.orgId, + roleId + }); + } + } + + logger.debug("User org info", { userOrgInfo }); + + let existingUserId = existingUser?.userId; + + // sync the user with the orgs and roles + await db.transaction(async (trx) => { + let userId = existingUser?.userId; + + // create user if not exists + if (!existingUser) { + userId = generateId(15); + + await trx.insert(users).values({ + userId, + username: userIdentifier, + email: email || null, + name: name || null, + type: UserType.OIDC, + idpId: existingIdp.idp.idpId, + emailVerified: true, // OIDC users are always verified + dateCreated: new Date().toISOString() }); } else { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User not found and auto-provisioning is disabled" + // set the name and email + await trx + .update(users) + .set({ + username: userIdentifier, + email: email || null, + name: name || null + }) + .where(eq(users.userId, userId)); + } + + existingUserId = userId; + + // get all current user orgs + const currentUserOrgs = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.userId, userId)); + + // Delete orgs that are no longer valid + const orgsToDelete = currentUserOrgs.filter( + (currentOrg) => + !userOrgInfo.some( + (newOrg) => newOrg.orgId === currentOrg.orgId + ) + ); + + if (orgsToDelete.length > 0) { + await trx.delete(userOrgs).where( + and( + eq(userOrgs.userId, userId), + inArray( + userOrgs.orgId, + orgsToDelete.map((org) => org.orgId) + ) ) ); } - } + + // Update roles for existing orgs where the role has changed + const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { + const newOrg = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + return newOrg && newOrg.roleId !== currentOrg.roleId; + }); + + if (orgsToUpdate.length > 0) { + for (const org of orgsToUpdate) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === org.orgId + ); + if (newRole) { + await trx + .update(userOrgs) + .set({ roleId: newRole.roleId }) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, org.orgId) + ) + ); + } + } + } + + // Add new orgs that don't exist yet + const orgsToAdd = userOrgInfo.filter( + (newOrg) => + !currentUserOrgs.some( + (currentOrg) => currentOrg.orgId === newOrg.orgId + ) + ); + + if (orgsToAdd.length > 0) { + await trx.insert(userOrgs).values( + orgsToAdd.map((org) => ({ + userId, + orgId: org.orgId, + roleId: org.roleId, + dateCreated: new Date().toISOString() + })) + ); + } + }); const token = generateSessionToken(); - const sess = await createIdpSession(token, userId); - const cookie = serializeIdpSessionCookie( - `p_idp_${orgId}.${idpId}`, - sess.idpSessionId, - req.protocol === "https", + const sess = await createSession(token, existingUserId); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, new Date(sess.expiresAt) ); - res.setHeader("Set-Cookie", cookie); + res.appendHeader("Set-Cookie", cookie); return response(res, { - data: {}, + data: { + redirectUrl: postAuthRedirectUrl + }, success: true, error: false, message: "OIDC callback validated successfully", diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index 20411b1a..4699ec8b 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -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)); } diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 2d95756d..6de12be9 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -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); diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 2763f809..f03cf0f0 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -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, diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index 31c7d8a5..2f80be90 100644 --- a/server/routers/user/getUser.ts +++ b/server/routers/user/getUser.ts @@ -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; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index eb9cdb61..042942ab 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -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, diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index c4215181..53b9f8e7 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,7 +1,7 @@ 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"; @@ -9,6 +9,7 @@ import { 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); } diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts index 6ec6784c..9a84852a 100644 --- a/server/setup/setupServerAdmin.ts +++ b/server/setup/setupServerAdmin.ts @@ -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, diff --git a/server/types/UserTypes.ts b/server/types/UserTypes.ts new file mode 100644 index 00000000..954d84f9 --- /dev/null +++ b/server/types/UserTypes.ts @@ -0,0 +1,4 @@ +export enum UserType { + Internal = "internal", + OIDC = "oidc" +} diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 29529d66..515bd2c2 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -24,7 +24,13 @@ import { useUserContext } from "@app/hooks/useUserContext"; export type UserRow = { id: string; - email: string; + email: string | null; + displayUsername: string | null; + username: string; + name: string | null; + idpId: number | null; + idpName: string; + type: string; status: string; role: string; isOwner: boolean; @@ -82,7 +88,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { Manage User - {userRow.email !== user?.email && ( + {userRow.username !== + user?.username && ( { setIsDeleteModalOpen( @@ -108,7 +115,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { } }, { - accessorKey: "email", + accessorKey: "displayUsername", header: ({ column }) => { return ( ); } }, { - accessorKey: "status", + accessorKey: "idpName", header: ({ column }) => { return ( ); @@ -185,7 +192,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { - @@ -239,7 +249,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {

Are you sure you want to remove{" "} - {selectedUser?.email} from the organization? + + {selectedUser?.email || + selectedUser?.name || + selectedUser?.username} + {" "} + from the organization?

@@ -250,14 +265,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {

- To confirm, please type the email address of the - user below. + To confirm, please type the name of the of the user + below.

} buttonText="Confirm Remove User" onConfirm={removeUser} - string={selectedUser?.email ?? ""} + string={ + selectedUser?.email || + selectedUser?.name || + selectedUser?.username || + "" + } title="Remove User from Organization" /> diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 8049ff96..f82cfdb0 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -70,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) { const userRows: UserRow[] = users.map((user) => { return { id: user.id, + username: user.username, + displayUsername: user.email || user.name || user.username, + name: user.name, email: user.email, + type: user.type, + idpId: user.idpId, + idpName: user.idpName || "Internal", status: "Confirmed", role: user.isOwner ? "Owner" : user.roleName || "Member", isOwner: user.isOwner || false diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 8f8e584c..750ec66e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -45,6 +45,7 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { useRouter } from "next/navigation"; +import { UserType } from "@server/types/UserTypes"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -175,7 +176,7 @@ export default function ResourceAuthenticationPage() { setAllUsers( usersResponse.data.data.users.map((user) => ({ id: user.id.toString(), - text: user.email + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` })) ); @@ -183,7 +184,7 @@ export default function ResourceAuthenticationPage() { "users", resourceUsersResponse.data.data.users.map((i) => ({ id: i.userId.toString(), - text: i.email + text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` })) ); diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index 0ead375d..12a6145c 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; export type GlobalUserRow = { id: string; - email: string; + name: string | null; + username: string; + email: string | null; + type: string; + idpId: number | null; + idpName: string; dateCreated: string; }; @@ -67,6 +72,22 @@ export default function UsersTable({ users }: Props) { ); } }, + { + accessorKey: "username", + header: ({ column }) => { + return ( + + ); + } + }, { accessorKey: "email", header: ({ column }) => { @@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) { ); } }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + header: ({ column }) => { + return ( + + ); + } + }, { id: "actions", cell: ({ row }) => { @@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {

Are you sure you want to permanently delete{" "} - {selected?.email || selected?.id} from - the server? + + {selected?.email || + selected?.name || + selected?.username} + {" "} + from the server?

@@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {

- To confirm, please type the email of the user + To confirm, please type the name of the user below.

} buttonText="Confirm Delete User" onConfirm={async () => deleteUser(selected!.id)} - string={selected.email} + string={ + selected.email || selected.name || selected.username + } title="Delete User from Server" /> )} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index a8ab19a2..877d1494 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -27,6 +27,11 @@ export default async function UsersPage(props: PageProps) { return { id: row.id, email: row.email, + name: row.name, + username: row.username, + type: row.type, + idpId: row.idpId, + idpName: row.idpName || "Internal", dateCreated: row.dateCreated, serverAdmin: row.serverAdmin }; diff --git a/src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx similarity index 57% rename from src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx rename to src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx index e7633bf7..6e0a1e45 100644 --- a/src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -4,56 +4,58 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp"; import { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; type ValidateOidcTokenParams = { orgId: string; idpId: string; code: string | undefined; - verifier: string | undefined; - storedState: string | undefined; expectedState: string | undefined; + stateCookie: string | undefined; }; export default function ValidateOidcToken(props: ValidateOidcTokenParams) { const { env } = useEnvContext(); const api = createApiClient({ env }); + const router = useRouter(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - if (!props.code || !props.verifier) { - setError("Missing code or verifier"); - setLoading(false); - return; - } - - if (!props.storedState) { - setError("Missing stored state"); - setLoading(false); - return; - } - - if (props.storedState !== props.expectedState) { - setError("Invalid state"); - setLoading(false); - return; - } - async function validate() { setLoading(true); + console.log("Validating OIDC token", { + code: props.code, + expectedState: props.expectedState, + stateCookie: props.stateCookie + }); + try { const res = await api.post< AxiosResponse - >( - `/auth/org/${props.orgId}/idp/${props.idpId}/oidc/validate-callback`, - { - code: props.code, - codeVerifier: props.verifier - } - ); + >(`/auth/idp/${props.idpId}/oidc/validate-callback`, { + code: props.code, + state: props.expectedState, + storedState: props.stateCookie + }); + + console.log("Validate OIDC token response", res.data); + + const redirectUrl = res.data.data.redirectUrl; + + if (!redirectUrl) { + router.push("/"); + } + + if (redirectUrl.startsWith("http")) { + window.location.href = res.data.data.redirectUrl; // TODO: validate this to make sure it's safe + } else { + router.push(res.data.data.redirectUrl); + } + } catch (e) { setError(formatAxiosError(e, "Error validating OIDC token")); } finally { diff --git a/src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx similarity index 76% rename from src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx rename to src/app/auth/idp/[idpId]/oidc/callback/page.tsx index 1eb49778..9e051f24 100644 --- a/src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -12,8 +12,7 @@ export default async function Page(props: { const searchParams = await props.searchParams; const allCookies = await cookies(); - const stateCookie = allCookies.get("oidc_state")?.value; - const verifier = allCookies.get("oidc_code_verifier")?.value; + const stateCookie = allCookies.get("p_oidc_state")?.value; return ( <> @@ -21,9 +20,8 @@ export default async function Page(props: { orgId={params.orgId} idpId={params.idpId} code={searchParams.code} - storedState={stateCookie} expectedState={searchParams.state} - verifier={verifier} + stateCookie={stateCookie} /> ); diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 033fa75d..10ad809f 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -36,7 +36,7 @@ export default async function Page(props: { return ( <> diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 91a1f52c..9075f155 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -35,7 +35,13 @@ export const orgNavItems: SidebarNavItem[] = [ children: [ { title: "Users", - href: "/{orgId}/settings/access/users" + href: "/{orgId}/settings/access/users", + children: [ + { + title: "Invitations", + href: "/{orgId}/settings/access/invitations" + } + ] }, { title: "Roles", diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 3be11528..6fa784d0 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -24,8 +24,8 @@ import { import { Alert, AlertDescription } from "@/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; -import { AxiosResponse } from "axios"; -import { formatAxiosError } from "@app/lib/api";; +import { AxiosResponse, AxiosResponse } from "axios"; +import { formatAxiosError } from "@app/lib/api"; import { LockIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -37,7 +37,8 @@ import { } from "./ui/input-otp"; import Link from "next/link"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; -import Image from 'next/image' +import Image from "next/image"; +import { GenerateOidcUrlResponse } from "@server/routers/idp"; type LoginFormProps = { redirect?: string; @@ -130,60 +131,93 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { setLoading(false); } + async function loginWithIdp(idpId: number) { + try { + const res = await api.post>( + `/auth/idp/${idpId}/oidc/generate-url`, + { + redirectUrl: redirect || "/" // this is the post auth redirect url + } + ); + + console.log(res); + + if (!res) { + setError("An error occurred while logging in"); + return; + } + + const data = res.data.data; + window.location.href = data.redirectUrl; + } catch (e) { + console.error(formatAxiosError(e)); + } + } + return (
{!mfaRequested && ( -
- - ( - - Email - - - - - - )} - /> - -
+ <> + + ( - Password + Email - + )} /> -
- - Forgot your password? - +
+ ( + + Password + + + + + + )} + /> + +
+ + Forgot your password? + +
-
- - + + + + + )} {mfaRequested && ( @@ -193,7 +227,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { Two-Factor Authentication

- Enter the code from your authenticator app or one of your single-use backup codes. + Enter the code from your authenticator app or one of + your single-use backup codes.

diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index 4b20afee..71b81465 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -38,7 +38,7 @@ export default function ProfileIcon() { const [openDisable2fa, setOpenDisable2fa] = useState(false); function getInitials() { - return user.email.substring(0, 1).toUpperCase(); + return (user.email || user.name || user.username).substring(0, 1).toUpperCase(); } function handleThemeChange(theme: "light" | "dark" | "system") { @@ -68,7 +68,7 @@ export default function ProfileIcon() {
- {user.email} + {user.email || user.name || user.username} @@ -92,7 +92,7 @@ export default function ProfileIcon() { Signed in as

- {user.email} + {user.email || user.name || user.username}

{user.serverAdmin && ( From aa3b527f67eccc9c0ab08b327e6649f0635e1331 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 14 Apr 2025 20:56:45 -0400 Subject: [PATCH 03/26] add validate callback loading state and encryption --- server/lib/config.ts | 13 ++++- server/lib/crypto.ts | 37 ++++++++++++++ server/routers/idp/createOidcIdp.ts | 19 +++++-- server/routers/idp/generateOidcUrl.ts | 16 +++++- server/routers/idp/validateOidcCallback.ts | 16 +++++- .../oidc/callback/ValidateOidcToken.tsx | 50 ++++++++++++++++--- .../auth/idp/[idpId]/oidc/callback/page.tsx | 14 ++++++ .../[resourceId]/ResourceAuthPortal.tsx | 2 +- src/app/globals.css | 2 +- src/components/LoginForm.tsx | 4 +- src/lib/cleanRedirect.ts | 4 +- 11 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 server/lib/crypto.ts diff --git a/server/lib/config.ts b/server/lib/config.ts index 9df1d7a7..8bac6809 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -92,7 +92,18 @@ const configSchema = z.object({ }) .optional(), trust_proxy: z.boolean().optional().default(true), - secret: z.string() + 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(), diff --git a/server/lib/crypto.ts b/server/lib/crypto.ts new file mode 100644 index 00000000..db248e8d --- /dev/null +++ b/server/lib/crypto.ts @@ -0,0 +1,37 @@ +import * as crypto from "crypto"; + +const ALGORITHM = "aes-256-gcm"; + +export function encrypt(value: string, key: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ALGORITHM, key, 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 decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + return decrypted.toString("utf8"); +} diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index e26064d7..b07d2872 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -9,6 +9,8 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { idp, idpOidcConfig, idpOrg, orgs } 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(); @@ -22,7 +24,8 @@ const bodySchema = z identifierPath: z.string().nonempty(), emailPath: z.string().optional(), namePath: z.string().optional(), - scopes: z.array(z.string().nonempty()) + scopes: z.array(z.string().nonempty()), + autoProvision: z.boolean().optional() }) .strict(); @@ -73,9 +76,15 @@ export async function createOidcIdp( identifierPath, emailPath, namePath, - name + 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 @@ -90,11 +99,11 @@ export async function createOidcIdp( await trx.insert(idpOidcConfig).values({ idpId: idpRes.idpId, - clientId, - clientSecret, + clientId: encryptedClientId, + clientSecret: encryptedSecret, authUrl, tokenUrl, - autoProvision: true, + autoProvision, scopes: JSON.stringify(scopes), identifierPath, emailPath, diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 4cb616e4..e73d6b45 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -13,6 +13,7 @@ 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({ @@ -77,10 +78,21 @@ export async function generateOidcUrl( const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes); + 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( - existingIdp.idpOidcConfig.clientId, - existingIdp.idpOidcConfig.clientSecret, + decryptedClientId, + decryptedClientSecret, redirectUrl ); diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 232838d1..1a78fb18 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -28,6 +28,7 @@ import { generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; +import { decrypt } from "@server/lib/crypto"; const paramsSchema = z .object({ @@ -90,10 +91,21 @@ export async function validateOidcCallback( ); } + 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( - existingIdp.idpOidcConfig.clientId, - existingIdp.idpOidcConfig.clientSecret, + decryptedClientId, + decryptedClientSecret, redirectUrl ); diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx index 6e0a1e45..1d6ec80e 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -6,6 +6,15 @@ import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp"; import { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardContent, + CardDescription +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, CheckCircle2, AlertCircle } from "lucide-react"; type ValidateOidcTokenParams = { orgId: string; @@ -13,6 +22,7 @@ type ValidateOidcTokenParams = { code: string | undefined; expectedState: string | undefined; stateCookie: string | undefined; + idp: {name: string}; }; export default function ValidateOidcToken(props: ValidateOidcTokenParams) { @@ -50,6 +60,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { router.push("/"); } + setLoading(false); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (redirectUrl.startsWith("http")) { window.location.href = res.data.data.redirectUrl; // TODO: validate this to make sure it's safe } else { @@ -67,11 +80,36 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { }, []); return ( - <> -

Validating OIDC Token...

- {loading &&

Loading...

} - {!loading &&

Token validated successfully!

} - {error &&

Error: {error}

} - +
+ + + Connecting to {props.idp.name} + Validating your identity + + + {loading && ( +
+ + Connecting... +
+ )} + {!loading && !error && ( +
+ + Connected +
+ )} + {error && ( + + + + There was a problem connecting to {props.idp.name}. Please contact your administrator. + {error} + + + )} +
+
+
); } diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx index 9e051f24..cba74790 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -1,5 +1,8 @@ import { cookies } from "next/headers"; import ValidateOidcToken from "./ValidateOidcToken"; +import { idp } from "@server/db/schemas"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; export default async function Page(props: { params: Promise<{ orgId: string; idpId: string }>; @@ -14,6 +17,16 @@ export default async function Page(props: { const allCookies = await cookies(); const stateCookie = allCookies.get("p_oidc_state")?.value; + // query db directly in server component because just need the name + const [idpRes] = await db + .select({ name: idp.name }) + .from(idp) + .where(eq(idp.idpId, parseInt(params.idpId!))); + + if (!idpRes) { + return
IdP not found
; + } + return ( <> ); diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 2480cd67..77dd5d51 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -490,7 +490,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { className={`${numMethods <= 1 ? "mt-0" : ""}`} > await handleSSOAuth() } diff --git a/src/app/globals.css b/src/app/globals.css index 7afe6079..21e7444e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -23,7 +23,7 @@ --border: hsl(20 5.9% 90%); --input: hsl(20 5.9% 75%); --ring: hsl(24.6 95% 53.1%); - --radius: 0.50rem; + --radius: 0.75rem; --chart-1: hsl(12 76% 61%); --chart-2: hsl(173 58% 39%); --chart-3: hsl(197 37% 24%); diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 6fa784d0..e4564c02 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -24,7 +24,7 @@ import { import { Alert, AlertDescription } from "@/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; -import { AxiosResponse, AxiosResponse } from "axios"; +import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/api"; import { LockIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; @@ -136,7 +136,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const res = await api.post>( `/auth/idp/${idpId}/oidc/generate-url`, { - redirectUrl: redirect || "/" // this is the post auth redirect url + redirectUrl: redirect || "/" } ); diff --git a/src/lib/cleanRedirect.ts b/src/lib/cleanRedirect.ts index dbe3651d..b573ab66 100644 --- a/src/lib/cleanRedirect.ts +++ b/src/lib/cleanRedirect.ts @@ -9,10 +9,10 @@ const patterns: PatternConfig[] = [ { name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ } ]; -export function cleanRedirect(input: string): string { +export function cleanRedirect(input: string, fallback?: string): string { if (!input || typeof input !== "string") { return "/"; } const isAccepted = patterns.some((pattern) => pattern.regex.test(input)); - return isAccepted ? input : "/"; + return isAccepted ? input : fallback || "/"; } From e86640547e40b0aff02d9d99f2022c39cb754a5c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 15 Apr 2025 09:26:25 -0400 Subject: [PATCH 04/26] add delete, get, list idp --- server/routers/external.ts | 24 ++++- server/routers/idp/createOidcIdp.ts | 6 +- server/routers/idp/deleteIdp.ts | 100 ++++++++++++++++++ server/routers/idp/getIdp.ts | 79 ++++++++++++++ server/routers/idp/index.ts | 3 + server/routers/idp/listIdps.ts | 94 ++++++++++++++++ .../settings/access/users/UsersTable.tsx | 4 +- 7 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 server/routers/idp/deleteIdp.ts create mode 100644 server/routers/idp/getIdp.ts create mode 100644 server/routers/idp/listIdps.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 6ad48c26..df44c599 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -499,7 +499,25 @@ authenticated.put( verifyUserIsServerAdmin, // verifyUserHasAction(ActionsEnum.createIdp), idp.createOidcIdp -) +); + +authenticated.delete( + "/idp/:idpId", + verifyUserIsServerAdmin, + idp.deleteIdp +); + +authenticated.get( + "/idp", + verifyUserIsServerAdmin, + idp.listIdps +); + +authenticated.get( + "/idp/:idpId", + verifyUserIsServerAdmin, + idp.getIdp +); // Auth routes export const authRouter = Router(); @@ -597,9 +615,9 @@ authRouter.post( authRouter.post( "/idp/:idpId/oidc/generate-url", idp.generateOidcUrl -) +); authRouter.post( "/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback -) +); diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index b07d2872..1090b060 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -36,9 +36,9 @@ export type CreateIdpResponse = { registry.registerPath({ method: "put", - path: "/org/{orgId}/idp/oidc", - description: "Create an OIDC IdP for an organization.", - tags: [OpenAPITags.Org, OpenAPITags.Idp], + path: "/idp/oidc", + description: "Create an OIDC IdP.", + tags: [OpenAPITags.Idp], request: { body: { content: { diff --git a/server/routers/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts new file mode 100644 index 00000000..e7878ed5 --- /dev/null +++ b/server/routers/idp/deleteIdp.ts @@ -0,0 +1,100 @@ +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, idpOrg } 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: "/org/{orgId}/idp/oidc", + description: "Create an OIDC IdP for an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Idp], + request: { + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function deleteIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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 IDP-org mappings + await trx + .delete(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + // Delete the IDP itself + await trx + .delete(idp) + .where(eq(idp.idpId, idpId)); + }); + + return response(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") + ); + } +} diff --git a/server/routers/idp/getIdp.ts b/server/routers/idp/getIdp.ts new file mode 100644 index 00000000..6598b542 --- /dev/null +++ b/server/routers/idp/getIdp.ts @@ -0,0 +1,79 @@ +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"; + +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>>; + +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 { + 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")); + } + + return response(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") + ); + } +} diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts index 70fbbcae..f9dfc5fc 100644 --- a/server/routers/idp/index.ts +++ b/server/routers/idp/index.ts @@ -1,3 +1,6 @@ export * from "./createOidcIdp"; +export * from "./deleteIdp"; +export * from "./listIdps"; export * from "./generateOidcUrl"; export * from "./validateOidcCallback"; +export * from "./getIdp"; diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts new file mode 100644 index 00000000..0a7c5e4c --- /dev/null +++ b/server/routers/idp/listIdps.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { domains, orgDomains, 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 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().from(orgDomains).limit(limit).offset(offset); + return res; +} + +export type ListIdpResponse = { + idps: NonNullable>>; + 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 { + 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`count(*)` }) + .from(domains); + + return response(res, { + data: { + idps: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Users retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 515bd2c2..ea642800 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -88,8 +88,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { Manage User - {userRow.username !== - user?.username && ( + {`${userRow.username}-${userRow.idpId}` !== + `${user?.username}-${userRow.idpId}` && ( { setIsDeleteModalOpen( From 432f38333e847aa69756f1044dc025bc5d4bbf38 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 15 Apr 2025 10:16:15 -0400 Subject: [PATCH 05/26] add create, delete, list for idp org policy --- server/routers/external.ts | 18 ++++ server/routers/idp/createIdpOrgPolicy.ts | 121 +++++++++++++++++++++++ server/routers/idp/deleteIdp.ts | 14 +-- server/routers/idp/deleteIdpOrgPolicy.ts | 90 +++++++++++++++++ server/routers/idp/getIdp.ts | 18 ++++ server/routers/idp/index.ts | 3 + server/routers/idp/listIdpOrgPolicies.ts | 116 ++++++++++++++++++++++ server/routers/idp/listIdps.ts | 4 +- server/routers/user/listUsers.ts | 5 +- 9 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 server/routers/idp/createIdpOrgPolicy.ts create mode 100644 server/routers/idp/deleteIdpOrgPolicy.ts create mode 100644 server/routers/idp/listIdpOrgPolicies.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index df44c599..cf7eac65 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -519,6 +519,24 @@ authenticated.get( idp.getIdp ); +authenticated.put( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.createIdpOrgPolicy +); + +authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.deleteIdpOrgPolicy +); + +authenticated.get( + "/idp/:idpId/org", + verifyUserIsServerAdmin, + idp.listIdpOrgPolicies +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/idp/createIdpOrgPolicy.ts b/server/routers/idp/createIdpOrgPolicy.ts new file mode 100644 index 00000000..13c5c1b8 --- /dev/null +++ b/server/routers/idp/createIdpOrgPolicy.ts @@ -0,0 +1,121 @@ +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 config from "@server/lib/config"; +import { eq, and } from "drizzle-orm"; +import { idp, idpOrg } from "@server/db/schemas"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + roleMapping: z.string().optional(), + orgMapping: z.string().optional() + }) + .strict(); + +export type CreateIdpOrgPolicyResponse = {}; + +registry.registerPath({ + method: "put", + path: "/idp/{idpId}/org/{orgId}", + description: "Create an IDP policy for an existing IDP on an organization.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createIdpOrgPolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + const { roleMapping, orgMapping } = parsedBody.data; + + const [existing] = await db + .select() + .from(idp) + .leftJoin(idpOrg, eq(idpOrg.orgId, orgId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))); + + if (!existing.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP org policy already exists." + ) + ); + } + + await db.insert(idpOrg).values({ + idpId, + orgId, + roleMapping, + orgMapping + }); + + return response(res, { + data: {}, + 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") + ); + } +} diff --git a/server/routers/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts index e7878ed5..ac84c4f7 100644 --- a/server/routers/idp/deleteIdp.ts +++ b/server/routers/idp/deleteIdp.ts @@ -18,17 +18,11 @@ const paramsSchema = z registry.registerPath({ method: "delete", - path: "/org/{orgId}/idp/oidc", - description: "Create an OIDC IdP for an organization.", - tags: [OpenAPITags.Org, OpenAPITags.Idp], + path: "/idp/{idpId}", + description: "Delete IDP.", + tags: [OpenAPITags.Idp], request: { - body: { - content: { - "application/json": { - schema: bodySchema - } - } - } + params: paramsSchema }, responses: {} }); diff --git a/server/routers/idp/deleteIdpOrgPolicy.ts b/server/routers/idp/deleteIdpOrgPolicy.ts new file mode 100644 index 00000000..9a6f6e72 --- /dev/null +++ b/server/routers/idp/deleteIdpOrgPolicy.ts @@ -0,0 +1,90 @@ +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, idpOrg } from "@server/db/schemas"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/idp/{idpId}/org/{orgId}", + description: "Create an OIDC IdP for an organization.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteIdpOrgPolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + + const [existing] = await db + .select() + .from(idp) + .leftJoin(idpOrg, eq(idpOrg.orgId, orgId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))); + + if (!existing.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (!existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A policy for this IDP and org does not exist." + ) + ); + } + + await db + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Policy deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/getIdp.ts b/server/routers/idp/getIdp.ts index 6598b542..794daade 100644 --- a/server/routers/idp/getIdp.ts +++ b/server/routers/idp/getIdp.ts @@ -9,6 +9,8 @@ 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({ @@ -63,6 +65,22 @@ export async function getIdp( 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(res, { data: idpRes, success: true, diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts index f9dfc5fc..b0b28256 100644 --- a/server/routers/idp/index.ts +++ b/server/routers/idp/index.ts @@ -4,3 +4,6 @@ export * from "./listIdps"; export * from "./generateOidcUrl"; export * from "./validateOidcCallback"; export * from "./getIdp"; +export * from "./createIdpOrgPolicy"; +export * from "./deleteIdpOrgPolicy"; +export * from "./listIdpOrgPolicies"; diff --git a/server/routers/idp/listIdpOrgPolicies.ts b/server/routers/idp/listIdpOrgPolicies.ts new file mode 100644 index 00000000..08ad110c --- /dev/null +++ b/server/routers/idp/listIdpOrgPolicies.ts @@ -0,0 +1,116 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idpOrg } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + idpId: z.coerce.number() +}); + +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(idpId: number, limit: number, offset: number) { + const res = await db + .select() + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)) + .limit(limit) + .offset(offset); + return res; +} + +export type ListIdpOrgPoliciesResponse = { + policies: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/idp/{idpId}/org", + description: "List all org policies on an IDP.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listIdpOrgPolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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 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(idpId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + return response(res, { + data: { + policies: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Policies retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index 0a7c5e4c..7b6f84e3 100644 --- a/server/routers/idp/listIdps.ts +++ b/server/routers/idp/listIdps.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { domains, orgDomains, users } from "@server/db/schemas"; +import { domains, idp, orgDomains, users } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -69,7 +69,7 @@ export async function listIdps( const [{ count }] = await db .select({ count: sql`count(*)` }) - .from(domains); + .from(idp); return response(res, { data: { diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 53b9f8e7..2b4a5d42 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -5,7 +5,7 @@ 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"; @@ -114,7 +114,8 @@ export async function listUsers( const [{ count }] = await db .select({ count: sql`count(*)` }) - .from(users); + .from(users) + .where(eq(userOrgs.orgId, orgId)); return response(res, { data: { From 36b62a5fe48ded9aa59073f8c2766de90619d583 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 16 Apr 2025 20:38:00 -0400 Subject: [PATCH 06/26] fix animations --- package-lock.json | 64 ++++++++++++++++++++++++++++-------- package.json | 5 +-- src/app/globals.css | 1 + src/components/ui/dialog.tsx | 4 +-- src/components/ui/toast.tsx | 2 +- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21acb5b6..812635e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@react-email/components": "0.0.36", "@react-email/render": "^1.0.6", "@react-email/tailwind": "1.0.4", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.20.6", "arctic": "^3.6.0", "axios": "1.8.4", @@ -76,7 +77,7 @@ "semver": "7.6.3", "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", - "tailwindcss-animate": "1.0.7", + "tw-animate-css": "^1.2.5", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", @@ -108,7 +109,7 @@ "esbuild-node-externals": "1.18.0", "postcss": "^8", "react-email": "4.0.6", - "tailwindcss": "^4.1.3", + "tailwindcss": "^4.1.4", "tsc-alias": "1.8.10", "tsx": "4.19.3", "typescript": "^5", @@ -4070,6 +4071,18 @@ "tslib": "^2.8.0" } }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz", @@ -4083,6 +4096,13 @@ "tailwindcss": "4.1.3" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", + "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz", @@ -4307,6 +4327,13 @@ "tailwindcss": "4.1.3" } }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", + "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/react-table": { "version": "8.20.6", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz", @@ -10206,6 +10233,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -15826,20 +15862,11 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", - "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", + "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", "license": "MIT" }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -16093,6 +16120,15 @@ "node": "*" } }, + "node_modules/tw-animate-css": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz", + "integrity": "sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 8aad99c2..2cca81ec 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@react-email/components": "0.0.36", "@react-email/render": "^1.0.6", "@react-email/tailwind": "1.0.4", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.20.6", "arctic": "^3.6.0", "axios": "1.8.4", @@ -87,7 +88,7 @@ "semver": "7.6.3", "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", - "tailwindcss-animate": "1.0.7", + "tw-animate-css": "^1.2.5", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", @@ -119,7 +120,7 @@ "esbuild-node-externals": "1.18.0", "postcss": "^8", "react-email": "4.0.6", - "tailwindcss": "^4.1.3", + "tailwindcss": "^4.1.4", "tsc-alias": "1.8.10", "tsx": "4.19.3", "typescript": "^5", diff --git a/src/app/globals.css b/src/app/globals.css index 21e7444e..e2a6e31a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,5 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap"); +@import 'tw-animate-css'; @import 'tailwindcss'; @custom-variant dark (&:is(.dark *)); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 48e36db4..ab2c497d 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< Date: Wed, 16 Apr 2025 20:49:06 -0400 Subject: [PATCH 07/26] more prominent sidebar sections --- src/app/navigation.tsx | 26 ++++---- src/components/SidebarNav.tsx | 120 +++++++++++++++++++--------------- 2 files changed, 81 insertions(+), 65 deletions(-) diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 9075f155..c77d665c 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -11,26 +11,26 @@ import { export const rootNavItems: SidebarNavItem[] = [ { title: "Home", - href: "/" - // icon: + href: "/", + icon: } ]; export const orgNavItems: SidebarNavItem[] = [ { title: "Sites", - href: "/{orgId}/settings/sites" - // icon: + href: "/{orgId}/settings/sites", + icon: }, { title: "Resources", - href: "/{orgId}/settings/resources" - // icon: + href: "/{orgId}/settings/resources", + icon: }, { title: "Access Control", href: "/{orgId}/settings/access", - // icon: , + icon: , autoExpand: true, children: [ { @@ -51,20 +51,20 @@ export const orgNavItems: SidebarNavItem[] = [ }, { title: "Shareable Links", - href: "/{orgId}/settings/share-links" - // icon: + href: "/{orgId}/settings/share-links", + icon: }, { title: "Settings", - href: "/{orgId}/settings/general" - // icon: + href: "/{orgId}/settings/general", + icon: } ]; export const adminNavItems: SidebarNavItem[] = [ { title: "All Users", - href: "/admin/users" - // icon: + href: "/admin/users", + icon: } ]; diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 39c9ac07..0d2c15af 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -35,26 +35,6 @@ export function SidebarNav({ const userId = params.userId as string; const [expandedItems, setExpandedItems] = useState>(new Set()); - // Initialize expanded items based on autoExpand property - useEffect(() => { - const autoExpanded = new Set(); - - function findAutoExpanded(items: SidebarNavItem[]) { - items.forEach(item => { - const hydratedHref = hydrateHref(item.href); - if (item.autoExpand) { - autoExpanded.add(hydratedHref); - } - if (item.children) { - findAutoExpanded(item.children); - } - }); - } - - findAutoExpanded(items); - setExpandedItems(autoExpanded); - }, [items]); - function hydrateHref(val: string): string { return val .replace("{orgId}", orgId) @@ -63,6 +43,34 @@ export function SidebarNav({ .replace("{userId}", userId); } + // Initialize expanded items based on autoExpand property and current path + useEffect(() => { + const autoExpanded = new Set(); + + function findAutoExpandedAndActivePath(items: SidebarNavItem[], parentHrefs: string[] = []) { + items.forEach(item => { + const hydratedHref = hydrateHref(item.href); + + // Add current item's href to the path + const currentPath = [...parentHrefs, hydratedHref]; + + // Auto expand if specified or if this item or any child is active + if (item.autoExpand || pathname.startsWith(hydratedHref)) { + // Expand all parent sections when a child is active + currentPath.forEach(href => autoExpanded.add(href)); + } + + // Recursively check children + if (item.children) { + findAutoExpandedAndActivePath(item.children, currentPath); + } + }); + } + + findAutoExpandedAndActivePath(items); + setExpandedItems(autoExpanded); + }, [items, pathname]); + function toggleItem(href: string) { setExpandedItems(prev => { const newSet = new Set(prev); @@ -86,41 +94,49 @@ export function SidebarNav({ return (
- { - if (disabled) { - e.preventDefault(); - } else if (onItemClick) { - onItemClick(); - } - }} - tabIndex={disabled ? -1 : undefined} - aria-disabled={disabled} > - {item.icon && {item.icon}} - {item.title} - - {hasChildren && ( - + )} +
{hasChildren && isExpanded && (
@@ -135,7 +151,7 @@ export function SidebarNav({ return (