successful log in loop poc

This commit is contained in:
miloschwartz 2025-04-13 17:57:27 -04:00
parent 7556a59e11
commit 53be2739bb
No known key found for this signature in database
37 changed files with 789 additions and 474 deletions

120
package-lock.json generated
View file

@ -56,6 +56,7 @@
"input-otp": "1.4.1", "input-otp": "1.4.1",
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "0.469.0", "lucide-react": "0.469.0",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.2.4", "next": "15.2.4",
@ -93,6 +94,7 @@
"@types/express": "5.0.0", "@types/express": "5.0.0",
"@types/jmespath": "^0.15.2", "@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22", "@types/node": "^22",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/react": "19.1.1", "@types/react": "19.1.1",
@ -4464,6 +4466,17 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"license": "MIT" "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": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -4471,6 +4484,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.14.1", "version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
@ -5590,6 +5610,12 @@
"ieee754": "^1.1.13" "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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -7048,6 +7074,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "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": { "node_modules/eciesjs": {
"version": "0.4.14", "version": "0.4.14",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz",
@ -9501,6 +9536,28 @@
"json5": "lib/cli.js" "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": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@ -9516,6 +9573,27 @@
"node": ">=4.0" "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": { "node_modules/keygrip": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@ -9837,12 +9915,54 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT" "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": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",

View file

@ -67,6 +67,7 @@
"input-otp": "1.4.1", "input-otp": "1.4.1",
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "0.469.0", "lucide-react": "0.469.0",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.2.4", "next": "15.2.4",
@ -104,6 +105,7 @@
"@types/express": "5.0.0", "@types/express": "5.0.0",
"@types/jmespath": "^0.15.2", "@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22", "@types/node": "^22",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/react": "19.1.1", "@types/react": "19.1.1",

View file

@ -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<IdpSession> {
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<IdpSessionValidationResult> {
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<void> {
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 };

View file

@ -106,8 +106,14 @@ export const exitNodes = sqliteTable("exitNodes", {
export const users = sqliteTable("user", { export const users = sqliteTable("user", {
userId: text("id").primaryKey(), userId: text("id").primaryKey(),
email: text("email").notNull().unique(), email: text("email"),
passwordHash: text("passwordHash").notNull(), 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" }) twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
@ -340,12 +346,6 @@ export const resourceSessions = sqliteTable("resourceSessions", {
.notNull() .notNull()
.default(false), .default(false),
isRequestToken: integer("isRequestToken", { mode: "boolean" }), isRequestToken: integer("isRequestToken", { mode: "boolean" }),
idpSessionId: text("idpSessionId").references(
() => idpSessions.idpSessionId,
{
onDelete: "cascade"
}
),
userSessionId: text("userSessionId").references(() => sessions.sessionId, { userSessionId: text("userSessionId").references(() => sessions.sessionId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
@ -424,6 +424,7 @@ export const supporterKey = sqliteTable("supporterKey", {
// Identity Providers // Identity Providers
export const idp = sqliteTable("idp", { export const idp = sqliteTable("idp", {
idpId: integer("idpId").primaryKey({ autoIncrement: true }), idpId: integer("idpId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull() type: text("type").notNull()
}); });
@ -445,9 +446,8 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
.notNull() .notNull()
.default(false), .default(false),
identifierPath: text("identifierPath").notNull(), identifierPath: text("identifierPath").notNull(),
emailPath: text("emailPath"), // by default, this is "email" emailPath: text("emailPath"),
namePath: text("namePath"), // by default, this is "name" namePath: text("namePath"),
roleMapping: text("roleMapping"),
scopes: text("scopes").notNull() scopes: text("scopes").notNull()
}); });
@ -455,41 +455,11 @@ export const idpOrg = sqliteTable("idpOrg", {
idpId: integer("idpId") idpId: integer("idpId")
.notNull() .notNull()
.references(() => idp.idpId, { onDelete: "cascade" }), .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") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
roleId: integer("roleId") roleMapping: text("roleMapping"),
.notNull() orgMapping: text("orgMapping")
.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<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
@ -528,7 +498,4 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>; export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>; export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>; export type Idp = InferSelectModel<typeof idp>;
export type IdpUser = InferSelectModel<typeof idpUser>;
export type IdpOrg = InferSelectModel<typeof idpOrg>; export type IdpOrg = InferSelectModel<typeof idpOrg>;
export type IdpUserOrg = InferSelectModel<typeof idpUserOrg>;
export type IdpSession = InferSelectModel<typeof idpSessions>;

View file

@ -91,7 +91,8 @@ const configSchema = z.object({
credentials: z.boolean().optional() credentials: z.boolean().optional()
}) })
.optional(), .optional(),
trust_proxy: z.boolean().optional().default(true) trust_proxy: z.boolean().optional().default(true),
secret: z.string()
}), }),
traefik: z.object({ traefik: z.object({
http_entrypoint: z.string(), http_entrypoint: z.string(),

View file

@ -1,8 +1,8 @@
import config from "@server/lib/config"; 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 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(); const redirectUrl = new URL(redirectPath, dashboardUrl).toString();
return redirectUrl; return redirectUrl;
} }

View file

@ -16,6 +16,7 @@ import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse"; import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app"; import { invalidateAllSessions } from "@server/auth/sessions/app";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export const changePasswordBody = z export const changePasswordBody = z
.object({ .object({
@ -50,6 +51,15 @@ export async function changePassword(
const { newPassword, oldPassword, code } = parsedBody.data; const { newPassword, oldPassword, code } = parsedBody.data;
const user = req.user as User; 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 { try {
if (newPassword === oldPassword) { if (newPassword === oldPassword) {
return next( return next(
@ -62,7 +72,7 @@ export async function changePassword(
const validPassword = await verifyPassword( const validPassword = await verifyPassword(
oldPassword, oldPassword,
user.passwordHash user.passwordHash!
); );
if (!validPassword) { if (!validPassword) {
return next(unauthorized()); return next(unauthorized());

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse"; import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const requestTotpSecretBody = z export const requestTotpSecretBody = z
.object({ .object({
@ -46,8 +47,17 @@ export async function requestTotpSecret(
const user = req.user as User; 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 { try {
const validPassword = await verifyPassword(password, user.passwordHash); const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) { if (!validPassword) {
return next(unauthorized()); return next(unauthorized());
} }
@ -63,7 +73,7 @@ export async function requestTotpSecret(
const hex = crypto.getRandomValues(new Uint8Array(20)); const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex); const secret = encodeHex(hex);
const uri = createTOTPKeyURI("Pangolin", user.email, hex); const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
await db await db
.update(users) .update(users)

View file

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

View file

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

View file

@ -495,9 +495,9 @@ authenticated.delete(
// ); // );
authenticated.put( authenticated.put(
"/org/:orgId/idp/oidc", "/idp/oidc",
verifyOrgAccess, verifyUserIsServerAdmin,
verifyUserHasAction(ActionsEnum.createIdp), // verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp idp.createOidcIdp
) )
@ -595,11 +595,11 @@ authRouter.post(
); );
authRouter.post( authRouter.post(
"/org/:orgId/idp/:idpId/oidc/generate-url", "/idp/:idpId/oidc/generate-url",
idp.generateOidcUrl idp.generateOidcUrl
) )
authRouter.post( authRouter.post(
"/org/:orgId/idp/:idpId/oidc/validate-callback", "/idp/:idpId/oidc/validate-callback",
idp.validateOidcCallback idp.validateOidcCallback
) )

View file

@ -8,27 +8,20 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas"; 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"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
const paramsSchema = z const paramsSchema = z.object({}).strict();
.object({
orgId: z.string()
})
.strict();
const bodySchema = z const bodySchema = z
.object({ .object({
name: z.string().nonempty(),
clientId: z.string().nonempty(), clientId: z.string().nonempty(),
clientSecret: z.string().nonempty(), clientSecret: z.string().nonempty(),
authUrl: z.string().url(), authUrl: z.string().url(),
tokenUrl: z.string().url(), tokenUrl: z.string().url(),
autoProvision: z.boolean(),
identifierPath: z.string().nonempty(), identifierPath: z.string().nonempty(),
emailPath: z.string().optional(), emailPath: z.string().optional(),
namePath: z.string().optional(), namePath: z.string().optional(),
roleMapping: z.string().optional(),
scopes: z.array(z.string().nonempty()) scopes: z.array(z.string().nonempty())
}) })
.strict(); .strict();
@ -44,7 +37,6 @@ registry.registerPath({
description: "Create an OIDC IdP for an organization.", description: "Create an OIDC IdP for an organization.",
tags: [OpenAPITags.Org, OpenAPITags.Idp], tags: [OpenAPITags.Org, OpenAPITags.Idp],
request: { request: {
params: paramsSchema,
body: { body: {
content: { content: {
"application/json": { "application/json": {
@ -62,16 +54,6 @@ export async function createOidcIdp(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { 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); const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) { if (!parsedBody.success) {
return next( return next(
@ -82,8 +64,6 @@ export async function createOidcIdp(
); );
} }
const { orgId } = parsedParams.data;
const { const {
clientId, clientId,
clientSecret, clientSecret,
@ -93,24 +73,15 @@ export async function createOidcIdp(
identifierPath, identifierPath,
emailPath, emailPath,
namePath, namePath,
roleMapping, name
autoProvision
} = parsedBody.data; } = 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; let idpId: number | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const [idpRes] = await trx const [idpRes] = await trx
.insert(idp) .insert(idp)
.values({ .values({
name,
type: "oidc" type: "oidc"
}) })
.returning(); .returning();
@ -123,21 +94,15 @@ export async function createOidcIdp(
clientSecret, clientSecret,
authUrl, authUrl,
tokenUrl, tokenUrl,
autoProvision, autoProvision: true,
scopes: JSON.stringify(scopes), scopes: JSON.stringify(scopes),
identifierPath, identifierPath,
emailPath, emailPath,
namePath, namePath
roleMapping
});
await trx.insert(idpOrg).values({
idpId: idpRes.idpId,
orgId
}); });
}); });
const redirectUrl = generateOidcRedirectUrl(orgId, idpId as number); const redirectUrl = generateOidcRedirectUrl(idpId as number);
return response<CreateIdpResponse>(res, { return response<CreateIdpResponse>(res, {
data: { data: {

View file

@ -11,14 +11,21 @@ import { and, eq } from "drizzle-orm";
import * as arctic from "arctic"; import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import cookie from "cookie"; import cookie from "cookie";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
const paramsSchema = z const paramsSchema = z
.object({ .object({
orgId: z.string(),
idpId: z.coerce.number() idpId: z.coerce.number()
}) })
.strict(); .strict();
const bodySchema = z
.object({
redirectUrl: z.string()
})
.strict();
export type GenerateOidcUrlResponse = { export type GenerateOidcUrlResponse = {
redirectUrl: string; 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 const [existingIdp] = await db
.select() .select()
.from(idp) .from(idp)
.innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId))
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where( .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
and(
eq(idpOrg.orgId, orgId),
eq(idp.type, "oidc"),
eq(idp.idpId, idpId)
)
);
if (!existingIdp) { if (!existingIdp) {
return next( return next(
@ -65,7 +77,7 @@ export async function generateOidcUrl(
const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes); const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes);
const redirectUrl = generateOidcRedirectUrl(orgId, idpId); const redirectUrl = generateOidcRedirectUrl(idpId);
const client = new arctic.OAuth2Client( const client = new arctic.OAuth2Client(
existingIdp.idpOidcConfig.clientId, existingIdp.idpOidcConfig.clientId,
existingIdp.idpOidcConfig.clientSecret, existingIdp.idpOidcConfig.clientSecret,
@ -82,15 +94,16 @@ export async function generateOidcUrl(
parsedScopes parsedScopes
); );
res.cookie("oidc_state", state, { const stateJwt = jsonwebtoken.sign(
path: "/", {
httpOnly: true, redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe
secure: req.protocol === "https", state,
expires: new Date(Date.now() + 60 * 10 * 1000), codeVerifier
sameSite: "lax" },
}); config.getRawConfig().server.secret
);
res.cookie(`oidc_code_verifier`, codeVerifier, { res.cookie("p_oidc_state", stateJwt, {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: req.protocol === "https", secure: req.protocol === "https",

View file

@ -10,34 +10,40 @@ import {
idp, idp,
idpOidcConfig, idpOidcConfig,
idpOrg, idpOrg,
idpUser,
idpUserOrg,
Role, Role,
roles roles,
userOrgs,
users
} from "@server/db/schemas"; } from "@server/db/schemas";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import * as arctic from "arctic"; import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import jmespath from "jmespath"; 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 { import {
createIdpSession, createSession,
serializeIdpSessionCookie generateId,
} from "@server/auth/sessions/orgIdp"; generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
const paramsSchema = z const paramsSchema = z
.object({ .object({
orgId: z.string(),
idpId: z.coerce.number() idpId: z.coerce.number()
}) })
.strict(); .strict();
const bodySchema = z.object({ const bodySchema = z.object({
code: z.string().nonempty(), 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( export async function validateOidcCallback(
req: Request, 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); const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) { 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 const [existingIdp] = await db
.select() .select()
.from(idp) .from(idp)
.innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId))
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where( .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
and(
eq(idpOrg.orgId, orgId),
eq(idp.type, "oidc"),
eq(idp.idpId, idpId)
)
);
if (!existingIdp) { if (!existingIdp) {
return next( return next(
@ -91,16 +90,61 @@ export async function validateOidcCallback(
); );
} }
const redirectUrl = generateOidcRedirectUrl( const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId);
orgId,
existingIdp.idp.idpId
);
const client = new arctic.OAuth2Client( const client = new arctic.OAuth2Client(
existingIdp.idpOidcConfig.clientId, existingIdp.idpOidcConfig.clientId,
existingIdp.idpOidcConfig.clientSecret, existingIdp.idpOidcConfig.clientSecret,
redirectUrl 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( const tokens = await client.validateAuthorizationCode(
existingIdp.idpOidcConfig.tokenUrl, existingIdp.idpOidcConfig.tokenUrl,
code, code,
@ -126,116 +170,214 @@ export async function validateOidcCallback(
logger.debug("User identifier", { userIdentifier }); logger.debug("User identifier", { userIdentifier });
const email = jmespath.search( let email = null;
let name = null;
try {
if (existingIdp.idpOidcConfig.emailPath) {
email = jmespath.search(
claims, claims,
existingIdp.idpOidcConfig.emailPath || "email" existingIdp.idpOidcConfig.emailPath
); );
const name = jmespath.search( }
if (existingIdp.idpOidcConfig.namePath) {
name = jmespath.search(
claims, claims,
existingIdp.idpOidcConfig.namePath || "name" existingIdp.idpOidcConfig.namePath || ""
); );
}
} catch (error) {}
logger.debug("User email", { email }); logger.debug("User email", { email });
logger.debug("User name", { name }); logger.debug("User name", { name });
const [existingIdpUser] = await db const [existingUser] = await db
.select() .select()
.from(idpUser) .from(users)
.innerJoin(idpUserOrg, eq(idpUserOrg.idpUserId, idpUser.idpUserId))
.where( .where(
and( and(
eq(idpUserOrg.orgId, orgId), eq(users.username, userIdentifier),
eq(idpUser.idpId, existingIdp.idp.idpId) eq(users.idpId, existingIdp.idp.idpId)
) )
); );
let userRole: Role | undefined; const idpOrgs = await db
if (existingIdp.idpOidcConfig.roleMapping) { .select()
const roleName = jmespath.search( .from(idpOrg)
claims, .where(eq(idpOrg.idpId, existingIdp.idp.idpId));
existingIdp.idpOidcConfig.roleMapping
); 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;
}
}
if (idpOrg.roleMapping) {
const roleName = jmespath.search(claims, idpOrg.roleMapping);
if (!roleName) { if (!roleName) {
return next( logger.error("Role name not found in the ID token", {
createHttpError( roleName
HttpCode.BAD_REQUEST, });
"Role mapping not found in the ID token" continue;
)
);
} }
const [roleRes] = await db const [roleRes] = await db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.orgId, orgId), eq(roles.name, roleName))); .where(
and(
userRole = roleRes; eq(roles.orgId, idpOrg.orgId),
} else { eq(roles.name, roleName)
// 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"
) )
); );
if (!roleRes) {
logger.error("Role not found", {
orgId: idpOrg.orgId,
roleName
});
continue;
} }
let userId: string | undefined = existingIdpUser?.idpUser.idpUserId; roleId = roleRes.roleId;
if (!existingIdpUser) {
if (existingIdp.idpOidcConfig.autoProvision) {
// TODO: Create the user and automatically assign roles
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) => { await db.transaction(async (trx) => {
const idpUserId = generateId(15); let userId = existingUser?.userId;
const [idpUserRes] = await trx // create user if not exists
.insert(idpUser) if (!existingUser) {
.values({ userId = generateId(15);
idpUserId,
await trx.insert(users).values({
userId,
username: userIdentifier,
email: email || null,
name: name || null,
type: UserType.OIDC,
idpId: existingIdp.idp.idpId, idpId: existingIdp.idp.idpId,
identifier: userIdentifier, emailVerified: true, // OIDC users are always verified
email, dateCreated: new Date().toISOString()
name
})
.returning();
await trx.insert(idpUserOrg).values({
idpUserId: idpUserRes.idpUserId,
orgId,
roleId: userRole.roleId
});
userId = idpUserRes.idpUserId;
}); });
} else { } else {
return next( // set the name and email
createHttpError( await trx
HttpCode.BAD_REQUEST, .update(users)
"User not found and auto-provisioning is disabled" .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 token = generateSessionToken();
const sess = await createIdpSession(token, userId); const sess = await createSession(token, existingUserId);
const cookie = serializeIdpSessionCookie( const isSecure = req.protocol === "https";
`p_idp_${orgId}.${idpId}`, const cookie = serializeSessionCookie(
sess.idpSessionId, token,
req.protocol === "https", isSecure,
new Date(sess.expiresAt) new Date(sess.expiresAt)
); );
res.setHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);
return response<ValidateOidcUrlCallbackResponse>(res, { return response<ValidateOidcUrlCallbackResponse>(res, {
data: {}, data: {
redirectUrl: postAuthRedirectUrl
},
success: true, success: true,
error: false, error: false,
message: "OIDC callback validated successfully", message: "OIDC callback validated successfully",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@ -9,6 +9,7 @@ import { sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const listUsersParamsSchema = z const listUsersParamsSchema = z
.object({ .object({
@ -41,14 +42,20 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
emailVerified: users.emailVerified, emailVerified: users.emailVerified,
dateCreated: users.dateCreated, dateCreated: users.dateCreated,
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId, roleId: userOrgs.roleId,
roleName: roles.name, roleName: roles.name,
isOwner: userOrgs.isOwner isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId
}) })
.from(users) .from(users)
.leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.where(sql`${userOrgs.orgId} = ${orgId}`) .leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId))
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
} }

View file

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

View file

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

View file

@ -24,7 +24,13 @@ import { useUserContext } from "@app/hooks/useUserContext";
export type UserRow = { export type UserRow = {
id: string; id: string;
email: string; email: string | null;
displayUsername: string | null;
username: string;
name: string | null;
idpId: number | null;
idpName: string;
type: string;
status: string; status: string;
role: string; role: string;
isOwner: boolean; isOwner: boolean;
@ -82,7 +88,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
Manage User Manage User
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
{userRow.email !== user?.email && ( {userRow.username !==
user?.username && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setIsDeleteModalOpen( setIsDeleteModalOpen(
@ -108,7 +115,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
} }
}, },
{ {
accessorKey: "email", accessorKey: "displayUsername",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@ -117,14 +124,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
Email Username
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
}, },
{ {
accessorKey: "status", accessorKey: "idpName",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@ -133,7 +140,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
Status Identity Provider
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -185,7 +192,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link <Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
> >
<Button variant={"outlinePrimary"} className="ml-2"> <Button
variant={"outlinePrimary"}
className="ml-2"
>
Manage Manage
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
@ -239,7 +249,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
Are you sure you want to remove{" "} Are you sure you want to remove{" "}
<b>{selectedUser?.email}</b> from the organization? <b>
{selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username}
</b>{" "}
from the organization?
</p> </p>
<p> <p>
@ -250,14 +265,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {
</p> </p>
<p> <p>
To confirm, please type the email address of the To confirm, please type the name of the of the user
user below. below.
</p> </p>
</div> </div>
} }
buttonText="Confirm Remove User" buttonText="Confirm Remove User"
onConfirm={removeUser} onConfirm={removeUser}
string={selectedUser?.email ?? ""} string={
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
}
title="Remove User from Organization" title="Remove User from Organization"
/> />

View file

@ -70,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) {
const userRows: UserRow[] = users.map((user) => { const userRows: UserRow[] = users.map((user) => {
return { return {
id: user.id, id: user.id,
username: user.username,
displayUsername: user.email || user.name || user.username,
name: user.name,
email: user.email, email: user.email,
type: user.type,
idpId: user.idpId,
idpName: user.idpName || "Internal",
status: "Confirmed", status: "Confirmed",
role: user.isOwner ? "Owner" : user.roleName || "Member", role: user.isOwner ? "Owner" : user.roleName || "Member",
isOwner: user.isOwner || false isOwner: user.isOwner || false

View file

@ -45,6 +45,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
const UsersRolesFormSchema = z.object({ const UsersRolesFormSchema = z.object({
roles: z.array( roles: z.array(
@ -175,7 +176,7 @@ export default function ResourceAuthenticationPage() {
setAllUsers( setAllUsers(
usersResponse.data.data.users.map((user) => ({ usersResponse.data.data.users.map((user) => ({
id: user.id.toString(), 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", "users",
resourceUsersResponse.data.data.users.map((i) => ({ resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(), id: i.userId.toString(),
text: i.email text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
})) }))
); );

View file

@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
export type GlobalUserRow = { export type GlobalUserRow = {
id: string; id: string;
email: string; name: string | null;
username: string;
email: string | null;
type: string;
idpId: number | null;
idpName: string;
dateCreated: string; dateCreated: string;
}; };
@ -67,6 +72,22 @@ export default function UsersTable({ users }: Props) {
); );
} }
}, },
{
accessorKey: "username",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Username
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{ {
accessorKey: "email", accessorKey: "email",
header: ({ column }) => { header: ({ column }) => {
@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) {
); );
} }
}, },
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Identity Provider
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
Are you sure you want to permanently delete{" "} Are you sure you want to permanently delete{" "}
<b>{selected?.email || selected?.id}</b> from <b>
the server? {selected?.email ||
selected?.name ||
selected?.username}
</b>{" "}
from the server?
</p> </p>
<p> <p>
@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {
</p> </p>
<p> <p>
To confirm, please type the email of the user To confirm, please type the name of the user
below. below.
</p> </p>
</div> </div>
} }
buttonText="Confirm Delete User" buttonText="Confirm Delete User"
onConfirm={async () => deleteUser(selected!.id)} onConfirm={async () => deleteUser(selected!.id)}
string={selected.email} string={
selected.email || selected.name || selected.username
}
title="Delete User from Server" title="Delete User from Server"
/> />
)} )}

View file

@ -27,6 +27,11 @@ export default async function UsersPage(props: PageProps) {
return { return {
id: row.id, id: row.id,
email: row.email, email: row.email,
name: row.name,
username: row.username,
type: row.type,
idpId: row.idpId,
idpName: row.idpName || "Internal",
dateCreated: row.dateCreated, dateCreated: row.dateCreated,
serverAdmin: row.serverAdmin serverAdmin: row.serverAdmin
}; };

View file

@ -4,56 +4,58 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp"; import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
type ValidateOidcTokenParams = { type ValidateOidcTokenParams = {
orgId: string; orgId: string;
idpId: string; idpId: string;
code: string | undefined; code: string | undefined;
verifier: string | undefined;
storedState: string | undefined;
expectedState: string | undefined; expectedState: string | undefined;
stateCookie: string | undefined;
}; };
export default function ValidateOidcToken(props: ValidateOidcTokenParams) { export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const router = useRouter();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { 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() { async function validate() {
setLoading(true); setLoading(true);
console.log("Validating OIDC token", {
code: props.code,
expectedState: props.expectedState,
stateCookie: props.stateCookie
});
try { try {
const res = await api.post< const res = await api.post<
AxiosResponse<ValidateOidcUrlCallbackResponse> AxiosResponse<ValidateOidcUrlCallbackResponse>
>( >(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
`/auth/org/${props.orgId}/idp/${props.idpId}/oidc/validate-callback`,
{
code: props.code, code: props.code,
codeVerifier: props.verifier 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) { } catch (e) {
setError(formatAxiosError(e, "Error validating OIDC token")); setError(formatAxiosError(e, "Error validating OIDC token"));
} finally { } finally {

View file

@ -12,8 +12,7 @@ export default async function Page(props: {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const allCookies = await cookies(); const allCookies = await cookies();
const stateCookie = allCookies.get("oidc_state")?.value; const stateCookie = allCookies.get("p_oidc_state")?.value;
const verifier = allCookies.get("oidc_code_verifier")?.value;
return ( return (
<> <>
@ -21,9 +20,8 @@ export default async function Page(props: {
orgId={params.orgId} orgId={params.orgId}
idpId={params.idpId} idpId={params.idpId}
code={searchParams.code} code={searchParams.code}
storedState={stateCookie}
expectedState={searchParams.state} expectedState={searchParams.state}
verifier={verifier} stateCookie={stateCookie}
/> />
</> </>
); );

View file

@ -36,7 +36,7 @@ export default async function Page(props: {
return ( return (
<> <>
<VerifyEmailForm <VerifyEmailForm
email={user.email} email={user.email!}
redirect={redirectUrl} redirect={redirectUrl}
/> />
</> </>

View file

@ -35,7 +35,13 @@ export const orgNavItems: SidebarNavItem[] = [
children: [ children: [
{ {
title: "Users", title: "Users",
href: "/{orgId}/settings/access/users" href: "/{orgId}/settings/access/users",
children: [
{
title: "Invitations",
href: "/{orgId}/settings/access/invitations"
}
]
}, },
{ {
title: "Roles", title: "Roles",

View file

@ -24,8 +24,8 @@ import {
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { LoginResponse } from "@server/routers/auth"; import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios"; import { AxiosResponse, AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react"; import { LockIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
@ -37,7 +37,8 @@ import {
} from "./ui/input-otp"; } from "./ui/input-otp";
import Link from "next/link"; import Link from "next/link";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; 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 = { type LoginFormProps = {
redirect?: string; redirect?: string;
@ -130,9 +131,33 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
setLoading(false); setLoading(false);
} }
async function loginWithIdp(idpId: number) {
try {
const res = await api.post<AxiosResponse<GenerateOidcUrlResponse>>(
`/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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{!mfaRequested && ( {!mfaRequested && (
<>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
@ -146,9 +171,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -184,6 +207,17 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
</div> </div>
</form> </form>
</Form> </Form>
<Button
type="button"
className="w-full"
onClick={() => {
loginWithIdp(1);
}}
>
OIDC Login
</Button>
</>
)} )}
{mfaRequested && ( {mfaRequested && (
@ -193,7 +227,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
Two-Factor Authentication Two-Factor Authentication
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
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.
</p> </p>
</div> </div>
<Form {...mfaForm}> <Form {...mfaForm}>

View file

@ -38,7 +38,7 @@ export default function ProfileIcon() {
const [openDisable2fa, setOpenDisable2fa] = useState(false); const [openDisable2fa, setOpenDisable2fa] = useState(false);
function getInitials() { 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") { function handleThemeChange(theme: "light" | "dark" | "system") {
@ -68,7 +68,7 @@ export default function ProfileIcon() {
<div className="flex items-center md:gap-4 grow min-w-0 gap-2 md:gap-0"> <div className="flex items-center md:gap-4 grow min-w-0 gap-2 md:gap-0">
<span className="truncate max-w-full font-medium min-w-0"> <span className="truncate max-w-full font-medium min-w-0">
{user.email} {user.email || user.name || user.username}
</span> </span>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -92,7 +92,7 @@ export default function ProfileIcon() {
Signed in as Signed in as
</p> </p>
<p className="text-xs leading-none text-muted-foreground"> <p className="text-xs leading-none text-muted-foreground">
{user.email} {user.email || user.name || user.username}
</p> </p>
</div> </div>
{user.serverAdmin && ( {user.serverAdmin && (