mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-12 21:30:35 +01:00
successful log in loop poc
This commit is contained in:
parent
7556a59e11
commit
53be2739bb
37 changed files with 789 additions and 474 deletions
120
package-lock.json
generated
120
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 };
|
|
@ -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<typeof orgs>;
|
||||
|
@ -528,7 +498,4 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
|||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
export type IdpUser = InferSelectModel<typeof idpUser>;
|
||||
export type IdpOrg = InferSelectModel<typeof idpOrg>;
|
||||
export type IdpUserOrg = InferSelectModel<typeof idpUserOrg>;
|
||||
export type IdpSession = InferSelectModel<typeof idpSessions>;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import logger from "@server/logger";
|
|||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { invalidateAllSessions } from "@server/auth/sessions/app";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const changePasswordBody = z
|
||||
.object({
|
||||
|
@ -50,6 +51,15 @@ export async function changePassword(
|
|||
const { newPassword, oldPassword, code } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (newPassword === oldPassword) {
|
||||
return next(
|
||||
|
@ -62,7 +72,7 @@ export async function changePassword(
|
|||
|
||||
const validPassword = await verifyPassword(
|
||||
oldPassword,
|
||||
user.passwordHash
|
||||
user.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
|
|
|
@ -14,6 +14,7 @@ import { sendEmail } from "@server/emails";
|
|||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||
import config from "@server/lib/config";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const disable2faBody = z
|
||||
.object({
|
||||
|
@ -47,8 +48,17 @@ export async function disable2fa(
|
|||
const { password, code } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
@ -99,11 +109,11 @@ export async function disable2fa(
|
|||
|
||||
sendEmail(
|
||||
TwoFactorAuthNotification({
|
||||
email: user.email,
|
||||
email: user.email!, // email is not null because we are checking user.type
|
||||
enabled: false
|
||||
}),
|
||||
{
|
||||
to: user.email,
|
||||
to: user.email!,
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
subject: "Two-factor authentication disabled"
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import db from "@server/db";
|
|||
import { users } from "@server/db/schemas";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
|
@ -17,6 +17,7 @@ import config from "@server/lib/config";
|
|||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const loginBodySchema = z
|
||||
.object({
|
||||
|
@ -69,7 +70,9 @@ export async function login(
|
|||
const existingUserRes = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email));
|
||||
.where(
|
||||
and(eq(users.type, UserType.Internal), eq(users.email, email))
|
||||
);
|
||||
if (!existingUserRes || !existingUserRes.length) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
|
@ -88,7 +91,7 @@ export async function login(
|
|||
|
||||
const validPassword = await verifyPassword(
|
||||
password,
|
||||
existingUser.passwordHash
|
||||
existingUser.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { User } from "@server/db/schemas";
|
|||
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export type RequestEmailVerificationCodeResponse = {
|
||||
codeSent: boolean;
|
||||
|
@ -28,6 +29,15 @@ export async function requestEmailVerificationCode(
|
|||
try {
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email verification is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
@ -37,7 +47,7 @@ export async function requestEmailVerificationCode(
|
|||
);
|
||||
}
|
||||
|
||||
await sendEmailVerificationCode(user.email, user.userId);
|
||||
await sendEmailVerificationCode(user.email!, user.userId);
|
||||
|
||||
return response<RequestEmailVerificationCodeResponse>(res, {
|
||||
data: {
|
||||
|
|
|
@ -74,7 +74,7 @@ export async function requestPasswordReset(
|
|||
|
||||
await trx.insert(passwordResetTokens).values({
|
||||
userId: existingUser[0].userId,
|
||||
email: existingUser[0].email,
|
||||
email: existingUser[0].email!,
|
||||
tokenHash,
|
||||
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
|
|||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const requestTotpSecretBody = z
|
||||
.object({
|
||||
|
@ -46,8 +47,17 @@ export async function requestTotpSecret(
|
|||
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
@ -63,7 +73,7 @@ export async function requestTotpSecret(
|
|||
|
||||
const hex = crypto.getRandomValues(new Uint8Array(20));
|
||||
const secret = encodeHex(hex);
|
||||
const uri = createTOTPKeyURI("Pangolin", user.email, hex);
|
||||
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
|
|
|
@ -8,7 +8,7 @@ import createHttpError from "http-errors";
|
|||
import response from "@server/lib/response";
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import {
|
||||
createSession,
|
||||
|
@ -21,6 +21,7 @@ import logger from "@server/logger";
|
|||
import { hashPassword } from "@server/auth/password";
|
||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const signupBodySchema = z.object({
|
||||
email: z
|
||||
|
@ -110,7 +111,9 @@ export async function signup(
|
|||
const existing = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email));
|
||||
.where(
|
||||
and(eq(users.email, email), eq(users.type, UserType.Internal))
|
||||
);
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
if (!config.getRawConfig().flags?.require_email_verification) {
|
||||
|
@ -157,6 +160,8 @@ export async function signup(
|
|||
|
||||
await db.insert(users).values({
|
||||
userId: userId,
|
||||
type: UserType.Internal,
|
||||
username: email,
|
||||
email: email,
|
||||
passwordHash,
|
||||
dateCreated: moment().toISOString()
|
||||
|
|
|
@ -14,6 +14,7 @@ import logger from "@server/logger";
|
|||
import { sendEmail } from "@server/emails";
|
||||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const verifyTotpBody = z
|
||||
.object({
|
||||
|
@ -48,6 +49,15 @@ export async function verifyTotp(
|
|||
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
@ -111,11 +121,11 @@ export async function verifyTotp(
|
|||
|
||||
sendEmail(
|
||||
TwoFactorAuthNotification({
|
||||
email: user.email,
|
||||
email: user.email!,
|
||||
enabled: true
|
||||
}),
|
||||
{
|
||||
to: user.email,
|
||||
to: user.email!,
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
subject: "Two-factor authentication enabled"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
|
@ -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<CreateIdpResponse>(res, {
|
||||
data: {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<ValidateOidcUrlCallbackResponse>(res, {
|
||||
data: {},
|
||||
data: {
|
||||
redirectUrl: postAuthRedirectUrl
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "OIDC callback validated successfully",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
|
||||
import { idp, userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -23,10 +23,15 @@ async function queryUsers(resourceId: number) {
|
|||
return await db
|
||||
.select({
|
||||
userId: userResources.userId,
|
||||
username: users.username,
|
||||
type: users.type,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
email: users.email
|
||||
})
|
||||
.from(userResources)
|
||||
.innerJoin(users, eq(userResources.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(userResources.resourceId, resourceId));
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import createHttpError from "http-errors";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { users } from "@server/db/schemas";
|
||||
import { idp, users } from "@server/db/schemas";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const listUsersSchema = z
|
||||
|
@ -31,10 +31,16 @@ async function queryUsers(limit: number, offset: number) {
|
|||
.select({
|
||||
id: users.userId,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
dateCreated: users.dateCreated,
|
||||
serverAdmin: users.serverAdmin
|
||||
serverAdmin: users.serverAdmin,
|
||||
type: users.type,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(users.serverAdmin, false))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
|
|
@ -17,6 +17,9 @@ async function queryUser(orgId: string, userId: string) {
|
|||
orgId: userOrgs.orgId,
|
||||
userId: users.userId,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db/schemas";
|
||||
import { idp, users } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -13,11 +13,17 @@ async function queryUser(userId: string) {
|
|||
.select({
|
||||
userId: users.userId,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
emailVerified: users.emailVerified,
|
||||
serverAdmin: users.serverAdmin
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
return user;
|
||||
|
|
|
@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error";
|
|||
import { sendEmail } from "@server/emails";
|
||||
import SendInviteLink from "@server/emails/templates/SendInviteLink";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
|
||||
|
||||
|
@ -115,7 +116,13 @@ export async function inviteUser(
|
|||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, email),
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(users.type, UserType.Internal)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length) {
|
||||
|
@ -190,7 +197,7 @@ export async function inviteUser(
|
|||
inviteLink,
|
||||
expiresInDays: (validHours / 24).toString(),
|
||||
orgName: org[0].name || orgId,
|
||||
inviterName: req.user?.email
|
||||
inviterName: req.user?.email || req.user?.username
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
|
@ -242,7 +249,7 @@ export async function inviteUser(
|
|||
inviteLink,
|
||||
expiresInDays: (validHours / 24).toString(),
|
||||
orgName: org[0].name || orgId,
|
||||
inviterName: req.user?.email
|
||||
inviterName: req.user?.email || req.user?.username
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
|
|
|
@ -1,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);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
|
|||
import moment from "moment";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export async function setupServerAdmin() {
|
||||
const {
|
||||
|
@ -34,7 +35,7 @@ export async function setupServerAdmin() {
|
|||
if (existing) {
|
||||
const passwordChanged = !(await verifyPassword(
|
||||
password,
|
||||
existing.passwordHash
|
||||
existing.passwordHash!
|
||||
));
|
||||
|
||||
if (passwordChanged) {
|
||||
|
@ -65,6 +66,8 @@ export async function setupServerAdmin() {
|
|||
await db.insert(users).values({
|
||||
userId: userId,
|
||||
email: email,
|
||||
type: UserType.Internal,
|
||||
username: email,
|
||||
passwordHash,
|
||||
dateCreated: moment().toISOString(),
|
||||
serverAdmin: true,
|
||||
|
|
4
server/types/UserTypes.ts
Normal file
4
server/types/UserTypes.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export enum UserType {
|
||||
Internal = "internal",
|
||||
OIDC = "oidc"
|
||||
}
|
|
@ -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
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{userRow.email !== user?.email && (
|
||||
{userRow.username !==
|
||||
user?.username && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(
|
||||
|
@ -108,7 +115,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
accessorKey: "displayUsername",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
|
@ -117,14 +124,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Email
|
||||
Username
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
accessorKey: "idpName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
|
@ -133,7 +140,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Status
|
||||
Identity Provider
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -185,7 +192,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
<Button
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
>
|
||||
Manage
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
@ -239,7 +249,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
<div className="space-y-4">
|
||||
<p>
|
||||
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>
|
||||
|
@ -250,14 +265,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email address of the
|
||||
user below.
|
||||
To confirm, please type the name of the of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Remove User"
|
||||
onConfirm={removeUser}
|
||||
string={selectedUser?.email ?? ""}
|
||||
string={
|
||||
selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username ||
|
||||
""
|
||||
}
|
||||
title="Remove User from Organization"
|
||||
/>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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})` : ""}`
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Username
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
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",
|
||||
cell: ({ row }) => {
|
||||
|
@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {
|
|||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<b>{selected?.email || selected?.id}</b> from
|
||||
the server?
|
||||
<b>
|
||||
{selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username}
|
||||
</b>{" "}
|
||||
from the server?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {
|
|||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email of the user
|
||||
To confirm, please type the name of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete User"
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={selected.email}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title="Delete User from Server"
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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<string | null>(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<ValidateOidcUrlCallbackResponse>
|
||||
>(
|
||||
`/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 {
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
|
@ -36,7 +36,7 @@ export default async function Page(props: {
|
|||
return (
|
||||
<>
|
||||
<VerifyEmailForm
|
||||
email={user.email}
|
||||
email={user.email!}
|
||||
redirect={redirectUrl}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<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 (
|
||||
<div className="space-y-4">
|
||||
{!mfaRequested && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
loginWithIdp(1);
|
||||
}}
|
||||
>
|
||||
OIDC Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mfaRequested && (
|
||||
|
@ -193,7 +227,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
Two-Factor Authentication
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
<Form {...mfaForm}>
|
||||
|
|
|
@ -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() {
|
|||
|
||||
<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">
|
||||
{user.email}
|
||||
{user.email || user.name || user.username}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
@ -92,7 +92,7 @@ export default function ProfileIcon() {
|
|||
Signed in as
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
{user.email || user.name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
{user.serverAdmin && (
|
||||
|
|
Loading…
Reference in a new issue