mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-13 05:40:38 +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",
|
"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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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", {
|
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>;
|
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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()
|
||||||
});
|
});
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
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 = {
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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})` : ""}`
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 {
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
|
@ -36,7 +36,7 @@ export default async function Page(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VerifyEmailForm
|
<VerifyEmailForm
|
||||||
email={user.email}
|
email={user.email!}
|
||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
Loading…
Reference in a new issue