From 53be2739bb49c7641a3d787e6ad9bdcf69d575de Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 13 Apr 2025 17:57:27 -0400 Subject: [PATCH] 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 f6634dd..21acb5b 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 4d44e3c..8aad99c 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 a8e4332..0000000 --- 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 65c8dc3..207237a 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 f6f4c44..9df1d7a 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 220c605..4eea973 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 3be9ef2..3b1e4c2 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 4564446..b10dd9b 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 aa4f0d5..eda637f 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 47747a9..0cc8825 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 20a6511..087352f 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 c60904c..a4f8bc4 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 833850c..564a137 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 a349d79..db4ec1a 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 09fd10a..6ad48c2 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 44f6a6b..e26064d 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 4b68f07..4cb616e 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 fd61528..232838d 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 20411b1..4699ec8 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 2d95756..6de12be 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 2763f80..f03cf0f 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 31c7d8a..2f80be9 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 eb9cdb6..042942a 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 c421518..53b9f8e 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 6ec6784..9a84852 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 0000000..954d84f --- /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 29529d6..515bd2c 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 8049ff9..f82cfdb 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 8f8e584..750ec66 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 0ead375..12a6145 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 a8ab19a..877d149 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 e7633bf..6e0a1e4 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 1eb4977..9e051f2 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 033fa75..10ad809 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 91a1f52..9075f15 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 3be1152..6fa784d 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 4b20afe..71b8146 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 && (