From 457e538ee0cd3e24df9887b03c1d7128137b522f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 12 Apr 2025 15:39:15 -0400 Subject: [PATCH] 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 ( + <> + + + ); +}