mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-19 16:47:49 +01:00
testing oidc callback
This commit is contained in:
parent
9cb215295a
commit
480a5f648d
15 changed files with 997 additions and 7 deletions
110
package-lock.json
generated
110
package-lock.json
generated
|
@ -33,13 +33,16 @@
|
||||||
"@react-email/render": "^1.0.6",
|
"@react-email/render": "^1.0.6",
|
||||||
"@react-email/tailwind": "1.0.4",
|
"@react-email/tailwind": "1.0.4",
|
||||||
"@tanstack/react-table": "8.20.6",
|
"@tanstack/react-table": "8.20.6",
|
||||||
|
"arctic": "^3.6.0",
|
||||||
"axios": "1.8.4",
|
"axios": "1.8.4",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
|
@ -51,6 +54,7 @@
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
|
"jmespath": "^0.16.0",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"lucide-react": "0.469.0",
|
"lucide-react": "0.469.0",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
|
@ -86,6 +90,7 @@
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.8",
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
|
@ -2749,6 +2754,21 @@
|
||||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@oslojs/jwt": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oslojs/encoding": "0.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@petamoriken/float16": {
|
"node_modules/@petamoriken/float16": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
||||||
|
@ -4173,6 +4193,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jmespath": {
|
||||||
|
"version": "0.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz",
|
||||||
|
"integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/js-yaml": {
|
"node_modules/@types/js-yaml": {
|
||||||
"version": "4.0.9",
|
"version": "4.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||||
|
@ -4857,6 +4884,17 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/arctic": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/arctic/-/arctic-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-egHDsCqEacb6oSHz5QSSxNhp07J+QJwJdPvs0katL+mNM5LaGQVqxmcdq1KwfaSNSAlVumBBs0MRExS88TxbMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oslojs/crypto": "1.0.1",
|
||||||
|
"@oslojs/encoding": "1.1.0",
|
||||||
|
"@oslojs/jwt": "0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
|
@ -5746,12 +5784,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie-parser": {
|
"node_modules/cookie-parser": {
|
||||||
|
@ -5767,12 +5805,34 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookies": {
|
||||||
|
"version": "0.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
|
||||||
|
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"keygrip": "~1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cors": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.5",
|
"version": "2.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
|
@ -6857,6 +6917,16 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/engine.io/node_modules/debug": {
|
"node_modules/engine.io/node_modules/debug": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
@ -9074,6 +9144,7 @@
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
|
||||||
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
|
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
|
@ -9120,6 +9191,15 @@
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jmespath": {
|
||||||
|
"version": "0.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
|
||||||
|
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
@ -9196,6 +9276,18 @@
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/keygrip": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tsscmp": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
@ -15778,6 +15870,15 @@
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsscmp": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.19.3",
|
"version": "4.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
|
||||||
|
@ -16103,6 +16204,7 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
|
||||||
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
|
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^3.1.1"
|
"isexe": "^3.1.1"
|
||||||
|
|
|
@ -44,13 +44,16 @@
|
||||||
"@react-email/render": "^1.0.6",
|
"@react-email/render": "^1.0.6",
|
||||||
"@react-email/tailwind": "1.0.4",
|
"@react-email/tailwind": "1.0.4",
|
||||||
"@tanstack/react-table": "8.20.6",
|
"@tanstack/react-table": "8.20.6",
|
||||||
|
"arctic": "^3.6.0",
|
||||||
"axios": "1.8.4",
|
"axios": "1.8.4",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
|
@ -62,6 +65,7 @@
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
|
"jmespath": "^0.16.0",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"lucide-react": "0.469.0",
|
"lucide-react": "0.469.0",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
|
@ -97,6 +101,7 @@
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.8",
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
|
|
|
@ -65,7 +65,8 @@ export enum ActionsEnum {
|
||||||
listResourceRules = "listResourceRules",
|
listResourceRules = "listResourceRules",
|
||||||
updateResourceRule = "updateResourceRule",
|
updateResourceRule = "updateResourceRule",
|
||||||
listOrgDomains = "listOrgDomains",
|
listOrgDomains = "listOrgDomains",
|
||||||
createNewt = "createNewt"
|
createNewt = "createNewt",
|
||||||
|
createIdp = "createIdp"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
126
server/auth/sessions/orgIdp.ts
Normal file
126
server/auth/sessions/orgIdp.ts
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
|
import {
|
||||||
|
IdpSession,
|
||||||
|
idpSessions,
|
||||||
|
IdpUser,
|
||||||
|
idpUser,
|
||||||
|
resourceSessions
|
||||||
|
} from "@server/db/schemas";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import cookie from "cookie";
|
||||||
|
|
||||||
|
const SESSION_COOKIE_EXPIRES =
|
||||||
|
1000 *
|
||||||
|
60 *
|
||||||
|
60 *
|
||||||
|
config.getRawConfig().server.dashboard_session_length_hours;
|
||||||
|
const COOKIE_DOMAIN =
|
||||||
|
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
|
||||||
|
|
||||||
|
export async function createIdpSession(
|
||||||
|
token: string,
|
||||||
|
idpUserId: string
|
||||||
|
): Promise<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 };
|
|
@ -340,6 +340,12 @@ 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"
|
||||||
}),
|
}),
|
||||||
|
@ -415,6 +421,77 @@ export const supporterKey = sqliteTable("supporterKey", {
|
||||||
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Identity Providers
|
||||||
|
export const idp = sqliteTable("idp", {
|
||||||
|
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
||||||
|
type: text("type").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identity Provider OAuth Configuration
|
||||||
|
export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||||
|
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
idpId: integer("idpId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||||
|
clientId: text("clientId").notNull(),
|
||||||
|
clientSecret: text("clientSecret").notNull(),
|
||||||
|
authUrl: text("authUrl").notNull(),
|
||||||
|
tokenUrl: text("tokenUrl").notNull(),
|
||||||
|
autoProvision: integer("autoProvision", {
|
||||||
|
mode: "boolean"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
identifierPath: text("identifierPath").notNull(),
|
||||||
|
emailPath: text("emailPath"), // by default, this is "email"
|
||||||
|
namePath: text("namePath"), // by default, this is "name"
|
||||||
|
roleMapping: text("roleMapping"),
|
||||||
|
scopes: text("scopes").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const idpOrg = sqliteTable("idpOrg", {
|
||||||
|
idpId: integer("idpId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
|
});
|
||||||
|
|
||||||
|
// IDP User
|
||||||
|
export const idpUser = sqliteTable("idpUser", {
|
||||||
|
idpUserId: text("idpUserId").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
idpId: integer("idpId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||||
|
email: text("email"),
|
||||||
|
name: text("name")
|
||||||
|
});
|
||||||
|
|
||||||
|
// IDP User Organization Link
|
||||||
|
export const idpUserOrg = sqliteTable("idpUserOrg", {
|
||||||
|
idpUserId: text("idpUserId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => idpUser.idpUserId, { onDelete: "cascade" }),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
roleId: integer("roleId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||||
|
});
|
||||||
|
|
||||||
|
export const idpSessions = sqliteTable("idpSessions", {
|
||||||
|
idpSessionId: text("idpSessionId").primaryKey(),
|
||||||
|
idpUserId: text("idpUserId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => idpUser.idpUserId, { onDelete: "cascade" }),
|
||||||
|
expiresAt: integer("expiresAt").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
@ -450,3 +527,8 @@ export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
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 IdpUser = InferSelectModel<typeof idpUser>;
|
||||||
|
export type IdpOrg = InferSelectModel<typeof idpOrg>;
|
||||||
|
export type IdpUserOrg = InferSelectModel<typeof idpUserOrg>;
|
||||||
|
export type IdpSession = InferSelectModel<typeof idpSessions>;
|
||||||
|
|
8
server/lib/idp/generateRedirectUrl.ts
Normal file
8
server/lib/idp/generateRedirectUrl.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
export function generateOidcRedirectUrl(orgId: string, idpId: number) {
|
||||||
|
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||||
|
const redirectPath = `/auth/org/${orgId}/idp/${idpId}/oidc/callback`;
|
||||||
|
const redirectUrl = new URL(redirectPath, dashboardUrl).toString();
|
||||||
|
return redirectUrl;
|
||||||
|
}
|
|
@ -11,5 +11,6 @@ export enum OpenAPITags {
|
||||||
Invitation = "Invitation",
|
Invitation = "Invitation",
|
||||||
Target = "Target",
|
Target = "Target",
|
||||||
Rule = "Rule",
|
Rule = "Rule",
|
||||||
AccessToken = "Access Token"
|
AccessToken = "Access Token",
|
||||||
|
Idp = "Identity Provider"
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import * as auth from "./auth";
|
||||||
import * as role from "./role";
|
import * as role from "./role";
|
||||||
import * as supporterKey from "./supporterKey";
|
import * as supporterKey from "./supporterKey";
|
||||||
import * as accessToken from "./accessToken";
|
import * as accessToken from "./accessToken";
|
||||||
|
import * as idp from "./idp";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
verifyAccessTokenAccess,
|
verifyAccessTokenAccess,
|
||||||
|
@ -493,6 +494,13 @@ authenticated.delete(
|
||||||
// createNewt
|
// createNewt
|
||||||
// );
|
// );
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/idp/oidc",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createIdp),
|
||||||
|
idp.createOidcIdp
|
||||||
|
)
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
@ -581,4 +589,17 @@ authRouter.post(
|
||||||
resource.authWithAccessToken
|
resource.authWithAccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
authRouter.post("/access-token", resource.authWithAccessToken);
|
authRouter.post(
|
||||||
|
"/access-token",
|
||||||
|
resource.authWithAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
authRouter.post(
|
||||||
|
"/org/:orgId/idp/:idpId/oidc/generate-url",
|
||||||
|
idp.generateOidcUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
authRouter.post(
|
||||||
|
"/org/:orgId/idp/:idpId/oidc/validate-callback",
|
||||||
|
idp.validateOidcCallback
|
||||||
|
)
|
||||||
|
|
158
server/routers/idp/createOidcIdp.ts
Normal file
158
server/routers/idp/createOidcIdp.ts
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { generateOidcUrl } from "./generateOidcUrl";
|
||||||
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
clientId: z.string().nonempty(),
|
||||||
|
clientSecret: z.string().nonempty(),
|
||||||
|
authUrl: z.string().url(),
|
||||||
|
tokenUrl: z.string().url(),
|
||||||
|
autoProvision: z.boolean(),
|
||||||
|
identifierPath: z.string().nonempty(),
|
||||||
|
emailPath: z.string().optional(),
|
||||||
|
namePath: z.string().optional(),
|
||||||
|
roleMapping: z.string().optional(),
|
||||||
|
scopes: z.array(z.string().nonempty())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CreateIdpResponse = {
|
||||||
|
idpId: number;
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/org/{orgId}/idp/oidc",
|
||||||
|
description: "Create an OIDC IdP for an organization.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.Idp],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createOidcIdp(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
authUrl,
|
||||||
|
tokenUrl,
|
||||||
|
scopes,
|
||||||
|
identifierPath,
|
||||||
|
emailPath,
|
||||||
|
namePath,
|
||||||
|
roleMapping,
|
||||||
|
autoProvision
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
// Check if the org exists
|
||||||
|
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idpId: number | undefined;
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const [idpRes] = await trx
|
||||||
|
.insert(idp)
|
||||||
|
.values({
|
||||||
|
type: "oidc"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
idpId = idpRes.idpId;
|
||||||
|
|
||||||
|
await trx.insert(idpOidcConfig).values({
|
||||||
|
idpId: idpRes.idpId,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
authUrl,
|
||||||
|
tokenUrl,
|
||||||
|
autoProvision,
|
||||||
|
scopes: JSON.stringify(scopes),
|
||||||
|
identifierPath,
|
||||||
|
emailPath,
|
||||||
|
namePath,
|
||||||
|
roleMapping
|
||||||
|
});
|
||||||
|
|
||||||
|
await trx.insert(idpOrg).values({
|
||||||
|
idpId: idpRes.idpId,
|
||||||
|
orgId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUrl = generateOidcRedirectUrl(orgId, idpId as number);
|
||||||
|
|
||||||
|
return response<CreateIdpResponse>(res, {
|
||||||
|
data: {
|
||||||
|
idpId: idpId as number,
|
||||||
|
redirectUrl
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Idp created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
116
server/routers/idp/generateOidcUrl.ts
Normal file
116
server/routers/idp/generateOidcUrl.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import * as arctic from "arctic";
|
||||||
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
|
import cookie from "cookie";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
orgId: z.string(),
|
||||||
|
idpId: z.coerce.number()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type GenerateOidcUrlResponse = {
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateOidcUrl(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, idpId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existingIdp] = await db
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId))
|
||||||
|
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(idpOrg.orgId, orgId),
|
||||||
|
eq(idp.type, "oidc"),
|
||||||
|
eq(idp.idpId, idpId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingIdp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"IdP not found for the organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes);
|
||||||
|
|
||||||
|
const redirectUrl = generateOidcRedirectUrl(orgId, idpId);
|
||||||
|
const client = new arctic.OAuth2Client(
|
||||||
|
existingIdp.idpOidcConfig.clientId,
|
||||||
|
existingIdp.idpOidcConfig.clientSecret,
|
||||||
|
redirectUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const codeVerifier = arctic.generateCodeVerifier();
|
||||||
|
const state = arctic.generateState();
|
||||||
|
const url = client.createAuthorizationURLWithPKCE(
|
||||||
|
existingIdp.idpOidcConfig.authUrl,
|
||||||
|
state,
|
||||||
|
arctic.CodeChallengeMethod.S256,
|
||||||
|
codeVerifier,
|
||||||
|
parsedScopes
|
||||||
|
);
|
||||||
|
|
||||||
|
res.cookie("oidc_state", state, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: req.protocol === "https",
|
||||||
|
expires: new Date(Date.now() + 60 * 10 * 1000),
|
||||||
|
sameSite: "lax"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie(`oidc_code_verifier`, codeVerifier, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: req.protocol === "https",
|
||||||
|
expires: new Date(Date.now() + 60 * 10 * 1000),
|
||||||
|
sameSite: "lax"
|
||||||
|
});
|
||||||
|
|
||||||
|
return response<GenerateOidcUrlResponse>(res, {
|
||||||
|
data: {
|
||||||
|
redirectUrl: url.toString()
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Idp auth url generated",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
3
server/routers/idp/index.ts
Normal file
3
server/routers/idp/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./createOidcIdp";
|
||||||
|
export * from "./generateOidcUrl";
|
||||||
|
export * from "./validateOidcCallback";
|
250
server/routers/idp/validateOidcCallback.ts
Normal file
250
server/routers/idp/validateOidcCallback.ts
Normal file
|
@ -0,0 +1,250 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
idp,
|
||||||
|
idpOidcConfig,
|
||||||
|
idpOrg,
|
||||||
|
idpUser,
|
||||||
|
idpUserOrg,
|
||||||
|
Role,
|
||||||
|
roles
|
||||||
|
} from "@server/db/schemas";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import * as arctic from "arctic";
|
||||||
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
|
import jmespath from "jmespath";
|
||||||
|
import { generateId, generateSessionToken } from "@server/auth/sessions/app";
|
||||||
|
import {
|
||||||
|
createIdpSession,
|
||||||
|
serializeIdpSessionCookie
|
||||||
|
} from "@server/auth/sessions/orgIdp";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
orgId: z.string(),
|
||||||
|
idpId: z.coerce.number()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
code: z.string().nonempty(),
|
||||||
|
codeVerifier: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ValidateOidcUrlCallbackResponse = {};
|
||||||
|
|
||||||
|
export async function validateOidcCallback(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, idpId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, codeVerifier } = parsedBody.data;
|
||||||
|
|
||||||
|
const [existingIdp] = await db
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId))
|
||||||
|
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(idpOrg.orgId, orgId),
|
||||||
|
eq(idp.type, "oidc"),
|
||||||
|
eq(idp.idpId, idpId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingIdp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"IdP not found for the organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = generateOidcRedirectUrl(
|
||||||
|
orgId,
|
||||||
|
existingIdp.idp.idpId
|
||||||
|
);
|
||||||
|
const client = new arctic.OAuth2Client(
|
||||||
|
existingIdp.idpOidcConfig.clientId,
|
||||||
|
existingIdp.idpOidcConfig.clientSecret,
|
||||||
|
redirectUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await client.validateAuthorizationCode(
|
||||||
|
existingIdp.idpOidcConfig.tokenUrl,
|
||||||
|
code,
|
||||||
|
codeVerifier
|
||||||
|
);
|
||||||
|
|
||||||
|
const idToken = tokens.idToken();
|
||||||
|
const claims = arctic.decodeIdToken(idToken);
|
||||||
|
|
||||||
|
const userIdentifier = jmespath.search(
|
||||||
|
claims,
|
||||||
|
existingIdp.idpOidcConfig.identifierPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userIdentifier) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User identifier not found in the ID token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("User identifier", { userIdentifier });
|
||||||
|
|
||||||
|
const email = jmespath.search(
|
||||||
|
claims,
|
||||||
|
existingIdp.idpOidcConfig.emailPath || "email"
|
||||||
|
);
|
||||||
|
const name = jmespath.search(
|
||||||
|
claims,
|
||||||
|
existingIdp.idpOidcConfig.namePath || "name"
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug("User email", { email });
|
||||||
|
logger.debug("User name", { name });
|
||||||
|
|
||||||
|
const [existingIdpUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(idpUser)
|
||||||
|
.innerJoin(idpUserOrg, eq(idpUserOrg.idpUserId, idpUser.idpUserId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(idpUserOrg.orgId, orgId),
|
||||||
|
eq(idpUser.idpId, existingIdp.idp.idpId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let userRole: Role | undefined;
|
||||||
|
if (existingIdp.idpOidcConfig.roleMapping) {
|
||||||
|
const roleName = jmespath.search(
|
||||||
|
claims,
|
||||||
|
existingIdp.idpOidcConfig.roleMapping
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!roleName) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Role mapping not found in the ID token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [roleRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.orgId, orgId), eq(roles.name, roleName)));
|
||||||
|
|
||||||
|
userRole = roleRes;
|
||||||
|
} else {
|
||||||
|
// TODO: Get the default role for this IDP?
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("User role", { userRole });
|
||||||
|
|
||||||
|
if (!userRole) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Role not found for the user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId: string | undefined = existingIdpUser?.idpUser.idpUserId;
|
||||||
|
if (!existingIdpUser) {
|
||||||
|
if (existingIdp.idpOidcConfig.autoProvision) {
|
||||||
|
// TODO: Create the user and automatically assign roles
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const idpUserId = generateId(15);
|
||||||
|
|
||||||
|
const [idpUserRes] = await trx
|
||||||
|
.insert(idpUser)
|
||||||
|
.values({
|
||||||
|
idpUserId,
|
||||||
|
idpId: existingIdp.idp.idpId,
|
||||||
|
identifier: userIdentifier,
|
||||||
|
email,
|
||||||
|
name
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await trx.insert(idpUserOrg).values({
|
||||||
|
idpUserId: idpUserRes.idpUserId,
|
||||||
|
orgId,
|
||||||
|
roleId: userRole.roleId
|
||||||
|
});
|
||||||
|
|
||||||
|
userId = idpUserRes.idpUserId;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User not found and auto-provisioning is disabled"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateSessionToken();
|
||||||
|
const sess = await createIdpSession(token, userId);
|
||||||
|
const cookie = serializeIdpSessionCookie(
|
||||||
|
`p_idp_${orgId}.${idpId}`,
|
||||||
|
sess.idpSessionId,
|
||||||
|
req.protocol === "https",
|
||||||
|
new Date(sess.expiresAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
return response<ValidateOidcUrlCallbackResponse>(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "OIDC callback validated successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -279,8 +279,20 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const res = await api
|
||||||
|
.post("/auth/org/home-lab/idp/1/oidc/generate-url")
|
||||||
|
.then((res) => {
|
||||||
|
if (res.data.data.redirectUrl) {
|
||||||
|
window.location.href = res.data.data.redirectUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Button onClick={async () => await test()}>Test</Button>
|
||||||
|
|
||||||
<CreateSiteFormModal
|
<CreateSiteFormModal
|
||||||
open={isCreateModalOpen}
|
open={isCreateModalOpen}
|
||||||
setOpen={setIsCreateModalOpen}
|
setOpen={setIsCreateModalOpen}
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type ValidateOidcTokenParams = {
|
||||||
|
orgId: string;
|
||||||
|
idpId: string;
|
||||||
|
code: string | undefined;
|
||||||
|
verifier: string | undefined;
|
||||||
|
storedState: string | undefined;
|
||||||
|
expectedState: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.code || !props.verifier) {
|
||||||
|
setError("Missing code or verifier");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.storedState) {
|
||||||
|
setError("Missing stored state");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.storedState !== props.expectedState) {
|
||||||
|
setError("Invalid state");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post<
|
||||||
|
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||||
|
>(
|
||||||
|
`/auth/org/${props.orgId}/idp/${props.idpId}/oidc/validate-callback`,
|
||||||
|
{
|
||||||
|
code: props.code,
|
||||||
|
codeVerifier: props.verifier
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatAxiosError(e, "Error validating OIDC token"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Validating OIDC Token...</h1>
|
||||||
|
{loading && <p>Loading...</p>}
|
||||||
|
{!loading && <p>Token validated successfully!</p>}
|
||||||
|
{error && <p>Error: {error}</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
30
src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx
Normal file
30
src/app/auth/org/[orgId]/idp/[idpId]/oidc/callback/page.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import ValidateOidcToken from "./ValidateOidcToken";
|
||||||
|
|
||||||
|
export default async function Page(props: {
|
||||||
|
params: Promise<{ orgId: string; idpId: string }>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
code: string;
|
||||||
|
state: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
|
||||||
|
const allCookies = await cookies();
|
||||||
|
const stateCookie = allCookies.get("oidc_state")?.value;
|
||||||
|
const verifier = allCookies.get("oidc_code_verifier")?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ValidateOidcToken
|
||||||
|
orgId={params.orgId}
|
||||||
|
idpId={params.idpId}
|
||||||
|
code={searchParams.code}
|
||||||
|
storedState={stateCookie}
|
||||||
|
expectedState={searchParams.state}
|
||||||
|
verifier={verifier}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue