From 11a5831b2c02e44bcb29759e2e604d92d1cf5f10 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 4 Jun 2026 11:47:53 -0400 Subject: [PATCH 1/8] build(deps): replace qrcode with openid-client Add openid-client@5 (CJS-compatible) for OIDC single sign-on and drop the now-unused qrcode dependency used by push-notification 2FA enrollment. --- create-a-container/package-lock.json | 310 +++++---------------------- create-a-container/package.json | 2 +- 2 files changed, 56 insertions(+), 256 deletions(-) diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index f6f86c3a..4d0236c0 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -17,8 +17,8 @@ "express-session-sequelize": "^2.3.0", "morgan": "^1.10.1", "nodemailer": "^8.0.5", + "openid-client": "^5.7.1", "pg": "^8.16.3", - "qrcode": "^1.5.4", "sequelize": "^6.37.8", "sequelize-cli": "^6.6.3", "sqlite3": "^6.0.1", @@ -443,15 +443,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -964,15 +955,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1023,12 +1005,6 @@ "node": ">=8" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -1412,19 +1388,6 @@ "node": ">= 0.8" } }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1941,6 +1904,15 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -1980,24 +1952,30 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2358,6 +2336,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2369,6 +2356,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2396,40 +2392,19 @@ "wrappy": "1" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" + "url": "https://github.com/sponsors/panva" } }, "node_modules/package-json-from-dist": { @@ -2446,15 +2421,6 @@ "node": ">= 0.8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2619,15 +2585,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2748,145 +2705,6 @@ "once": "^1.3.1" } }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/qrcode/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2998,12 +2816,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3219,12 +3031,6 @@ "node": ">= 18" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -3901,12 +3707,6 @@ "node": ">= 8" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, "node_modules/wkx": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", diff --git a/create-a-container/package.json b/create-a-container/package.json index 72a38bf3..7d41c53e 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -30,8 +30,8 @@ "express-session-sequelize": "^2.3.0", "morgan": "^1.10.1", "nodemailer": "^8.0.5", + "openid-client": "^5.7.1", "pg": "^8.16.3", - "qrcode": "^1.5.4", "sequelize": "^6.37.8", "sequelize-cli": "^6.6.3", "sqlite3": "^6.0.1", From 5182bf15df808af8ce873c8b6c4843725229a4c1 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 4 Jun 2026 11:48:01 -0400 Subject: [PATCH 2/8] feat(auth): add OIDC client module, user fields, and JIT provisioning - utils/oidc.js: config-driven OIDC helper (enabled only when issuer, client id, and secret are all set) with PKCE/state/nonce, discovery, and callback handling. - Add oidcSubject (unique) and oidcIssuer columns via migration and model. - User.findOrProvisionFromOidc() matches by subject, then email, and optionally just-in-time provisions accounts with a random unusable password; add User.uniqueUid() helper. --- ...20260604000001-add-oidc-fields-to-users.js | 21 +++ create-a-container/models/user.js | 105 ++++++++++++++ create-a-container/utils/oidc.js | 136 ++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 create-a-container/migrations/20260604000001-add-oidc-fields-to-users.js create mode 100644 create-a-container/utils/oidc.js diff --git a/create-a-container/migrations/20260604000001-add-oidc-fields-to-users.js b/create-a-container/migrations/20260604000001-add-oidc-fields-to-users.js new file mode 100644 index 00000000..1874614b --- /dev/null +++ b/create-a-container/migrations/20260604000001-add-oidc-fields-to-users.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Users', 'oidcSubject', { + type: Sequelize.STRING(255), + allowNull: true, + unique: true + }); + await queryInterface.addColumn('Users', 'oidcIssuer', { + type: Sequelize.STRING(255), + allowNull: true + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('Users', 'oidcIssuer'); + await queryInterface.removeColumn('Users', 'oidcSubject'); + } +}; diff --git a/create-a-container/models/user.js b/create-a-container/models/user.js index 2c261a6a..de1a315e 100644 --- a/create-a-container/models/user.js +++ b/create-a-container/models/user.js @@ -48,6 +48,102 @@ module.exports = (sequelize, DataTypes) => { this.userPassword = plainPassword; await this.save(); } + + /** + * Generate a unique `uid` from a desired base, appending a numeric suffix + * if the base is already taken. + * @param {string} base - Desired username + * @returns {Promise} + */ + static async uniqueUid(base) { + const sanitized = (base || 'user') + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '') + .replace(/^[._-]+/, '') || 'user'; + let candidate = sanitized; + let suffix = 1; + // eslint-disable-next-line no-await-in-loop + while (await User.findOne({ where: { uid: candidate } })) { + candidate = `${sanitized}${suffix}`; + suffix += 1; + } + return candidate; + } + + /** + * Resolve a local account from validated OIDC claims, optionally creating + * one when just-in-time provisioning is enabled. + * + * Matching order: + * 1. existing link by oidcSubject + * 2. existing local user by email (the OIDC identity is then linked) + * 3. JIT-provisioned new user (only when jitEnabled) + * + * @param {object} claims - Normalized claims from utils/oidc handleCallback + * @param {object} opts + * @param {boolean} opts.jitEnabled - Whether provisioning is permitted + * @returns {Promise<{user: User|null, code?: string}>} + */ + static async findOrProvisionFromOidc(claims, { jitEnabled } = {}) { + const includeGroups = { include: [{ association: 'groups' }] }; + + if (claims.sub) { + const linked = await User.findOne({ + where: { oidcSubject: claims.sub }, + ...includeGroups, + }); + if (linked) return { user: linked }; + } + + if (claims.email) { + const byEmail = await User.findOne({ + where: { mail: claims.email }, + ...includeGroups, + }); + if (byEmail) { + // Link the OIDC identity to the existing local account. + if (!byEmail.oidcSubject && claims.sub) { + byEmail.oidcSubject = claims.sub; + byEmail.oidcIssuer = claims.issuer || null; + await byEmail.save(); + } + return { user: byEmail }; + } + } + + if (!jitEnabled) { + return { user: null, code: 'no_account' }; + } + + if (!claims.email) { + return { user: null, code: 'missing_email' }; + } + + const crypto = require('crypto'); + const base = claims.preferredUsername || claims.email.split('@')[0]; + const uid = await User.uniqueUid(base); + const givenName = (claims.givenName || claims.name || uid).trim(); + const familyName = (claims.familyName || '').trim() || givenName; + + await User.create({ + uidNumber: await User.nextUidNumber(), + uid, + givenName, + sn: familyName, + cn: claims.name?.trim() || `${givenName} ${familyName}`.trim(), + mail: claims.email, + // OIDC users authenticate via the IdP; store a random unusable secret + // so the NOT NULL password column is satisfied without a known password. + userPassword: crypto.randomBytes(32).toString('hex'), + status: 'active', + homeDirectory: `/home/${uid}`, + oidcSubject: claims.sub || null, + oidcIssuer: claims.issuer || null, + }); + + const created = await User.findOne({ where: { uid }, ...includeGroups }); + return { user: created }; + } } User.init({ uidNumber: { @@ -103,6 +199,15 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'pending' + }, + oidcSubject: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true + }, + oidcIssuer: { + type: DataTypes.STRING(255), + allowNull: true } }, { sequelize, diff --git a/create-a-container/utils/oidc.js b/create-a-container/utils/oidc.js new file mode 100644 index 00000000..816dfd04 --- /dev/null +++ b/create-a-container/utils/oidc.js @@ -0,0 +1,136 @@ +'use strict'; + +/** + * OIDC (OpenID Connect) integration. + * + * Configuration is read from environment variables. OIDC is considered + * "enabled" only when the issuer, client id, and client secret are all set. + * When enabled, the login screen redirects to the IdP and internal + * password login / self-registration are disabled. + * + * Env vars: + * OIDC_ISSUER_URL Discovery base URL of the IdP (required) + * OIDC_CLIENT_ID OAuth2 client id (required) + * OIDC_CLIENT_SECRET OAuth2 client secret (required) + * OIDC_REDIRECT_URI Absolute callback URL registered with the IdP. + * If unset, it is derived from the request host + * as `${protocol}://${host}/api/v1/auth/oidc/callback`. + * OIDC_SCOPES Space-separated scopes (default "openid profile email") + * OIDC_JIT_PROVISION "true" to auto-create users on first login + * OIDC_POST_LOGOUT_REDIRECT_URI Optional RP-initiated logout return URL + */ + +const { Issuer, generators } = require('openid-client'); + +const CALLBACK_PATH = '/api/v1/auth/oidc/callback'; + +function isOidcEnabled() { + return Boolean( + process.env.OIDC_ISSUER_URL && + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET, + ); +} + +function isJitProvisioningEnabled() { + return (process.env.OIDC_JIT_PROVISION || '').toLowerCase() === 'true'; +} + +function getScopes() { + return (process.env.OIDC_SCOPES || 'openid profile email').trim(); +} + +// Derive the redirect URI: prefer the explicit env var, otherwise build it +// from the incoming request so a single deployment works without extra config. +function getRedirectUri(req) { + const configured = (process.env.OIDC_REDIRECT_URI || '').trim(); + if (configured) return configured; + const proto = req.protocol; + const host = req.get('host'); + return `${proto}://${host}${CALLBACK_PATH}`; +} + +// Lazily discover the issuer and build a Client. Cached after first success. +let cachedClient = null; +async function getClient(redirectUri) { + if (!isOidcEnabled()) { + throw new Error('OIDC is not configured'); + } + if (!cachedClient) { + const issuer = await Issuer.discover(process.env.OIDC_ISSUER_URL); + cachedClient = new issuer.Client({ + client_id: process.env.OIDC_CLIENT_ID, + client_secret: process.env.OIDC_CLIENT_SECRET, + redirect_uris: redirectUri ? [redirectUri] : undefined, + response_types: ['code'], + }); + } + return cachedClient; +} + +/** + * Build the authorization URL and the transient values that must be stored in + * the session and replayed during the callback (PKCE verifier, state, nonce). + */ +async function buildAuthorizationRequest(req) { + const redirectUri = getRedirectUri(req); + const client = await getClient(redirectUri); + + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + const state = generators.state(); + const nonce = generators.nonce(); + + const url = client.authorizationUrl({ + scope: getScopes(), + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + nonce, + }); + + return { url, codeVerifier, state, nonce, redirectUri }; +} + +/** + * Complete the authorization-code exchange and validate the ID token. + * Returns the normalized identity claims. + */ +async function handleCallback(req, { codeVerifier, state, nonce, redirectUri }) { + const client = await getClient(redirectUri); + const params = client.callbackParams(req); + const tokenSet = await client.callback(redirectUri, params, { + code_verifier: codeVerifier, + state, + nonce, + }); + + const claims = tokenSet.claims(); + return { + sub: claims.sub, + issuer: claims.iss, + email: claims.email ? String(claims.email).toLowerCase().trim() : null, + emailVerified: claims.email_verified, + preferredUsername: claims.preferred_username || null, + givenName: claims.given_name || null, + familyName: claims.family_name || null, + name: claims.name || null, + }; +} + +function getPostLogoutRedirectUri() { + return (process.env.OIDC_POST_LOGOUT_REDIRECT_URI || '').trim() || null; +} + +module.exports = { + CALLBACK_PATH, + isOidcEnabled, + isJitProvisioningEnabled, + getScopes, + getRedirectUri, + getClient, + buildAuthorizationRequest, + handleCallback, + getPostLogoutRedirectUri, +}; From 3e21a3fd0d87856e5dc5000a9043582f038ae21a Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 4 Jun 2026 11:48:06 -0400 Subject: [PATCH 3/8] feat(auth): add OIDC login flow and disable internal login when enabled - Add GET /auth/oidc/login and /auth/oidc/callback routes implementing the authorization-code flow with session-stored PKCE/state/nonce. - When OIDC is configured, password login and self-registration return 403 (oidc_enabled); remove the push-notification 2FA challenge logic. - Expose oidcEnabled via /health so the SPA can auto-redirect to the IdP; stop returning pushNotificationUrl from /session. --- create-a-container/routers/api/v1/auth.js | 230 ++++++++------------- create-a-container/routers/api/v1/index.js | 28 +-- 2 files changed, 93 insertions(+), 165 deletions(-) diff --git a/create-a-container/routers/api/v1/auth.js b/create-a-container/routers/api/v1/auth.js index 0be58d3e..695bc987 100644 --- a/create-a-container/routers/api/v1/auth.js +++ b/create-a-container/routers/api/v1/auth.js @@ -1,47 +1,37 @@ /** - * /api/v1/auth — login, logout, register, password reset, 2FA polling. + * /api/v1/auth — login, logout, register, password reset, OIDC SSO. * - * Login flow: + * Login flow (internal password auth): * 1. POST /login { username, password } - * → 200 { data: { user, isAdmin } } // no 2FA configured, logged in - * → 200 { data: { challengeId, requires2FA: true } } // push 2FA enqueued - * → 401 { error } // bad credentials - * 2. GET /login/challenge/:id (poll) - * → 200 { data: { status: 'pending' } } - * → 200 { data: { status: 'approved', user, isAdmin } } (session now active) - * → 200 { data: { status: 'rejected' | 'timeout' | 'failed', message } } + * → 200 { data: { user, isAdmin, redirect } } // logged in + * → 401 { error } // bad credentials + * → 403 { error: oidc_enabled } // internal login disabled + * + * OIDC flow (when an IdP is configured): + * 1. GET /oidc/login → 302 redirect to the IdP authorization endpoint + * 2. GET /oidc/callback → 302 redirect into the SPA with an active session */ const express = require('express'); -const QRCode = require('qrcode'); const { Op } = require('sequelize'); const { User, - Group, - Setting, ExternalDomain, PasswordResetToken, InviteToken, } = require('../../../models'); const { sendPasswordResetEmail } = require('../../../utils/email'); -const { sendPushNotificationInvite } = require('../../../utils/push-notification-invite'); const { isSafeRedirectUrl } = require('../../../utils'); +const { + isOidcEnabled, + isJitProvisioningEnabled, + buildAuthorizationRequest, + handleCallback, +} = require('../../../utils/oidc'); const { asyncHandler, ok, created, ApiError } = require('../../../middlewares/api'); const router = express.Router(); -// In-memory challenge store for 2FA flows. -// Keyed by challengeId; values expire after 5 minutes. -const challenges = new Map(); -const CHALLENGE_TTL_MS = 5 * 60 * 1000; -function newChallengeId() { - return require('crypto').randomBytes(16).toString('hex'); -} -function setChallenge(id, value) { - challenges.set(id, value); - setTimeout(() => challenges.delete(id), CHALLENGE_TTL_MS).unref?.(); -} - async function safeRedirectUrl(redirect) { let url = redirect || '/'; const domains = await ExternalDomain.findAll({ attributes: ['name'] }); @@ -62,6 +52,7 @@ async function activateSession(req, user) { // session. The route is not registered at all when NODE_ENV === 'production' // so it cannot be reached even by misconfiguration. if (process.env.NODE_ENV !== 'production') { + const { Group } = require('../../../models'); router.post( '/dev', asyncHandler(async (req, res) => { @@ -114,6 +105,12 @@ if (process.env.NODE_ENV !== 'production') { router.post( '/login', asyncHandler(async (req, res) => { + // When an IdP is configured, internal password login is disabled — users + // must authenticate through the identity provider. + if (isOidcEnabled()) { + throw new ApiError(403, 'oidc_enabled', 'Password login is disabled. Sign in with your identity provider.'); + } + const { username, password, redirect } = req.body || {}; if (!username || !password) { throw new ApiError(400, 'invalid_request', 'username and password are required'); @@ -130,107 +127,70 @@ router.post( throw new ApiError(403, 'account_inactive', 'Account is not active. Contact an administrator.'); } - const settings = await Setting.getMultiple([ - 'push_notification_url', - 'push_notification_enabled', - ]); - const pushEnabled = - settings.push_notification_enabled === 'true' && - (settings.push_notification_url || '').trim() !== ''; - const safeRedirect = await safeRedirectUrl(redirect); + await activateSession(req, user); + return ok(res, { + user: user.uid, + isAdmin: req.session.isAdmin, + redirect: safeRedirect, + }); + }), +); - if (!pushEnabled) { - await activateSession(req, user); - return ok(res, { - user: user.uid, - isAdmin: req.session.isAdmin, - redirect: safeRedirect, - }); +// GET /api/v1/auth/oidc/login — begin the OIDC authorization-code flow. +router.get( + '/oidc/login', + asyncHandler(async (req, res) => { + if (!isOidcEnabled()) { + throw new ApiError(404, 'oidc_disabled', 'OIDC is not configured'); } - - // 2FA push challenge — start it in the background; client polls /login/challenge/:id. - const challengeId = newChallengeId(); - setChallenge(challengeId, { status: 'pending', userId: user.uidNumber, redirect: safeRedirect }); - - (async () => { - try { - const payload = { - username: user.uid, - title: 'Authentication Request', - body: 'Please review and respond to your pending authentication request.', - actions: [ - { icon: 'approve', title: 'Approve', callback: 'approve' }, - { icon: 'reject', title: 'Reject', callback: 'reject' }, - ], - }; - const response = await fetch(`${settings.push_notification_url}/send-notification`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const result = await response.json().catch(() => ({})); - - if ( - result.success === false && - (result.error?.includes('No device found with this Username') || - result.error?.includes('User not found')) - ) { - setChallenge(challengeId, { - status: 'unregistered', - registrationUrl: settings.push_notification_url, - }); - return; - } - if (!response.ok) { - setChallenge(challengeId, { status: 'failed', message: 'Push notification send failed' }); - return; - } - if (result.action === 'approve') { - setChallenge(challengeId, { - status: 'approved', - userId: user.uidNumber, - redirect: safeRedirect, - }); - } else if (result.action === 'reject') { - setChallenge(challengeId, { status: 'rejected', message: 'Second factor denied' }); - } else if (result.action === 'timeout') { - setChallenge(challengeId, { status: 'timeout', message: 'Second factor timed out' }); - } else { - setChallenge(challengeId, { - status: 'failed', - message: `Second factor failed: ${result.action || 'unknown'}`, - }); - } - } catch (err) { - console.error('2FA push error:', err); - setChallenge(challengeId, { status: 'failed', message: 'Push notification error' }); - } - })(); - - return ok(res, { challengeId, requires2FA: true }); + const safeRedirect = await safeRedirectUrl(req.query.redirect); + const { url, codeVerifier, state, nonce, redirectUri } = await buildAuthorizationRequest(req); + req.session.oidc = { codeVerifier, state, nonce, redirectUri, redirect: safeRedirect }; + await new Promise((resolve, reject) => + req.session.save((err) => (err ? reject(err) : resolve())), + ); + return res.redirect(url); }), ); -// GET /api/v1/auth/login/challenge/:id +// GET /api/v1/auth/oidc/callback — complete the flow and start a session. router.get( - '/login/challenge/:id', + '/oidc/callback', asyncHandler(async (req, res) => { - const ch = challenges.get(req.params.id); - if (!ch) throw new ApiError(404, 'challenge_not_found', 'Challenge expired or not found'); - if (ch.status === 'approved') { - const user = await User.findByPk(ch.userId, { include: [{ association: 'groups' }] }); - if (!user) throw new ApiError(500, 'user_missing', 'User no longer exists'); - await activateSession(req, user); - challenges.delete(req.params.id); - return ok(res, { - status: 'approved', - user: user.uid, - isAdmin: req.session.isAdmin, - redirect: ch.redirect || '/', + if (!isOidcEnabled()) { + throw new ApiError(404, 'oidc_disabled', 'OIDC is not configured'); + } + const pending = req.session.oidc; + const fail = (code) => res.redirect(`/login?oidc_error=${encodeURIComponent(code)}`); + + if (!pending) return fail('expired'); + delete req.session.oidc; + + let claims; + try { + claims = await handleCallback(req, pending); + } catch (err) { + console.error('OIDC callback error:', err); + return fail('exchange_failed'); + } + + let result; + try { + result = await User.findOrProvisionFromOidc(claims, { + jitEnabled: isJitProvisioningEnabled(), }); + } catch (err) { + console.error('OIDC provisioning error:', err); + return fail('provisioning_failed'); } - return ok(res, { status: ch.status, message: ch.message, registrationUrl: ch.registrationUrl }); + + const user = result.user; + if (!user) return fail(result.code || 'no_account'); + if (user.status !== 'active') return fail('account_inactive'); + + await activateSession(req, user); + return res.redirect(pending.redirect || '/'); }), ); @@ -252,6 +212,9 @@ router.post( router.get( '/register/invite/:token', asyncHandler(async (req, res) => { + if (isOidcEnabled()) { + throw new ApiError(403, 'oidc_enabled', 'Self-registration is disabled. Sign in with your identity provider.'); + } const invite = await InviteToken.validateToken(req.params.token); if (!invite) throw new ApiError(404, 'invalid_invite', 'Invalid or expired invitation'); return ok(res, { email: invite.email }); @@ -262,6 +225,9 @@ router.get( router.post( '/register', asyncHandler(async (req, res) => { + if (isOidcEnabled()) { + throw new ApiError(403, 'oidc_enabled', 'Self-registration is disabled. Sign in with your identity provider.'); + } const { uid, givenName: rawGiven, sn: rawSn, mail, userPassword, inviteToken } = req.body || {}; if (!uid || !rawGiven || !rawSn || !mail || !userPassword) { throw new ApiError(400, 'invalid_request', 'All registration fields are required'); @@ -302,48 +268,16 @@ router.post( await User.create(userParams); if (validatedInvite) await validatedInvite.markAsUsed(); - let twoFactor = null; - if (isInvitedUser) { - const inviteResult = await sendPushNotificationInvite(userParams); - if (inviteResult?.success && inviteResult.inviteUrl) { - try { - const parsed = new URL(inviteResult.inviteUrl); - if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { - const tk = parsed.searchParams.get('token'); - if (tk) twoFactor = { enrollmentToken: tk }; - } - } catch { - /* invalid URL */ - } - } else if (inviteResult?.error) { - twoFactor = { warning: inviteResult.error }; - } - } return created(res, { uid, status, message: isInvitedUser ? 'Account created. You can now log in.' : 'Account registered. You will be notified once approved.', - ...(twoFactor ? { twoFactor } : {}), }); }), ); -// GET /api/v1/auth/register/2fa-qr/:token — produces a QR code for the push-notification enrollment URL -router.get( - '/register/2fa-qr/:token', - asyncHandler(async (req, res) => { - const notificationUrl = await Setting.get('push_notification_url'); - if (!notificationUrl?.trim()) { - throw new ApiError(404, 'push_not_configured', 'Push notifications are not configured'); - } - const url = `${notificationUrl.trim()}/register?token=${encodeURIComponent(req.params.token)}`; - const qrCodeDataUri = await QRCode.toDataURL(url, { width: 256 }); - return ok(res, { qrCodeDataUri, inviteUrl: url }); - }), -); - // POST /api/v1/auth/password-reset/request router.post( '/password-reset/request', diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index ad3804ae..d89f371f 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -34,9 +34,15 @@ router.get('/csrf-token', (req, res) => { }); // Health check (unauthenticated). Exposes `isDev` so the SPA can render -// non-production helpers like one-click dev login buttons. +// non-production helpers like one-click dev login buttons, and `oidcEnabled` +// so the login screen can auto-redirect to the configured identity provider. +const { isOidcEnabled } = require('../../../utils/oidc'); router.get('/health', (_req, res) => - ok(res, { status: 'ok', isDev: process.env.NODE_ENV !== 'production' }), + ok(res, { + status: 'ok', + isDev: process.env.NODE_ENV !== 'production', + oidcEnabled: isOidcEnabled(), + }), ); // OpenAPI v1 spec (unauthenticated) @@ -51,24 +57,12 @@ router.use(csrfGuard); // Auth routes (login/register/reset are intentionally outside apiAuth) router.use('/auth', require('./auth')); -// Authenticated session check. Admins also receive `pushNotificationUrl` when -// configured so the sidebar can render the MFA Admin link. +// Authenticated session check. router.get('/session', apiAuth, async (req, res) => { - const payload = { + return ok(res, { user: req.session.user, isAdmin: !!req.session.isAdmin, - }; - if (req.session.isAdmin) { - try { - const { Setting } = require('../../../models'); - const url = await Setting.get('push_notification_url'); - payload.pushNotificationUrl = url?.trim() || ''; - } catch (err) { - console.error('Failed to load pushNotificationUrl for session:', err); - payload.pushNotificationUrl = ''; - } - } - return ok(res, payload); + }); }); // Resource routes — each sub-router applies its own apiAuth/apiAdmin From 17b811408078ddbc0352733bac4096a426f7eca8 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 4 Jun 2026 11:48:13 -0400 Subject: [PATCH 4/8] refactor: remove push-notification 2FA backend MFA is now delegated to the OIDC identity provider, so the bespoke push-approval 2FA is removed: - Drop the push_notification_* settings (GET/PUT/validation) and the twoFactorWarning invite path from user creation. - Delete utils/push-notification-invite.js and the dead push_notification_url lookup in the currentSite middleware. - Add a migration that removes the obsolete push_notification_* settings. --- create-a-container/middlewares/currentSite.js | 12 +--- ...00002-remove-push-notification-settings.js | 40 +++++++++++++ create-a-container/routers/api/v1/settings.js | 18 +----- create-a-container/routers/api/v1/users.js | 11 +--- .../utils/push-notification-invite.js | 58 ------------------- 5 files changed, 43 insertions(+), 96 deletions(-) create mode 100644 create-a-container/migrations/20260604000002-remove-push-notification-settings.js delete mode 100644 create-a-container/utils/push-notification-invite.js diff --git a/create-a-container/middlewares/currentSite.js b/create-a-container/middlewares/currentSite.js index e1a3fd8c..ace144de 100644 --- a/create-a-container/middlewares/currentSite.js +++ b/create-a-container/middlewares/currentSite.js @@ -1,4 +1,4 @@ -const { Site, Setting } = require('../models'); +const { Site } = require('../models'); // Middleware to set req.session.currentSite based on the :siteId parameter function setCurrentSite(req, res, next) { @@ -10,8 +10,6 @@ function setCurrentSite(req, res, next) { } // Middleware to load all sites and attach to res.locals for use in views. -// Also exposes a small set of layout-wide settings (e.g. push notification URL, -// used by the sidebar to render the MFA Admin link). async function loadSites(req, res, next) { try { const sites = await Site.findAll({ @@ -26,14 +24,6 @@ async function loadSites(req, res, next) { res.locals.currentSite = null; } - try { - const pushNotificationUrl = await Setting.get('push_notification_url'); - res.locals.pushNotificationUrl = pushNotificationUrl?.trim() || ''; - } catch (error) { - console.error('Error loading push notification URL:', error); - res.locals.pushNotificationUrl = ''; - } - next(); } diff --git a/create-a-container/migrations/20260604000002-remove-push-notification-settings.js b/create-a-container/migrations/20260604000002-remove-push-notification-settings.js new file mode 100644 index 00000000..1b7a5811 --- /dev/null +++ b/create-a-container/migrations/20260604000002-remove-push-notification-settings.js @@ -0,0 +1,40 @@ +'use strict'; + +// Removes the obsolete push-notification 2FA settings. Push-approval 2FA has +// been removed in favor of delegating MFA to an OIDC identity provider. +const PUSH_KEYS = [ + 'push_notification_url', + 'push_notification_enabled', + 'push_notification_api_key', +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `DELETE FROM "Settings" WHERE key IN (:keys)`, + { replacements: { keys: PUSH_KEYS } }, + ); + }, + + async down(queryInterface) { + // Re-create the keys with empty/default values so a rollback restores the + // previous schema shape (values themselves are not recoverable). + const now = new Date(); + const rows = [ + { key: 'push_notification_url', value: '' }, + { key: 'push_notification_enabled', value: 'false' }, + { key: 'push_notification_api_key', value: '' }, + ].map((r) => ({ ...r, createdAt: now, updatedAt: now })); + for (const row of rows) { + // Avoid duplicate-key errors if a row somehow already exists. + const [existing] = await queryInterface.sequelize.query( + `SELECT key FROM "Settings" WHERE key = :key`, + { replacements: { key: row.key } }, + ); + if (existing.length === 0) { + await queryInterface.bulkInsert('Settings', [row]); + } + } + }, +}; diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index 2e6fbb70..7e76b7c8 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -4,16 +4,13 @@ const express = require('express'); const { Setting } = require('../../../models'); -const { apiAuth, apiAdmin, asyncHandler, ok, ApiError } = require('../../../middlewares/api'); +const { apiAuth, apiAdmin, asyncHandler, ok } = require('../../../middlewares/api'); const router = express.Router(); router.use(apiAuth, apiAdmin); const KEYS = [ - 'push_notification_url', - 'push_notification_enabled', - 'push_notification_api_key', 'smtp_url', 'smtp_noreply_address', 'default_container_env_vars', @@ -30,9 +27,6 @@ router.get( /* malformed JSON — treat as empty */ } return ok(res, { - pushNotificationUrl: settings.push_notification_url || '', - pushNotificationEnabled: settings.push_notification_enabled === 'true', - pushNotificationApiKey: settings.push_notification_api_key || '', smtpUrl: settings.smtp_url || '', smtpNoreplyAddress: settings.smtp_noreply_address || '', defaultContainerEnvVars, @@ -44,18 +38,11 @@ router.put( '/', asyncHandler(async (req, res) => { const { - pushNotificationUrl, - pushNotificationEnabled, - pushNotificationApiKey, smtpUrl, smtpNoreplyAddress, defaultContainerEnvVars, } = req.body || {}; - if (pushNotificationEnabled === true && (!pushNotificationUrl || pushNotificationUrl.trim() === '')) { - throw new ApiError(400, 'invalid_request', 'pushNotificationUrl is required when push notifications are enabled'); - } - const envVars = []; if (Array.isArray(defaultContainerEnvVars)) { for (const e of defaultContainerEnvVars) { @@ -69,9 +56,6 @@ router.put( } } - await Setting.set('push_notification_url', pushNotificationUrl || ''); - await Setting.set('push_notification_enabled', pushNotificationEnabled ? 'true' : 'false'); - await Setting.set('push_notification_api_key', pushNotificationApiKey || ''); await Setting.set('smtp_url', smtpUrl || ''); await Setting.set('smtp_noreply_address', smtpNoreplyAddress || ''); await Setting.set('default_container_env_vars', JSON.stringify(envVars)); diff --git a/create-a-container/routers/api/v1/users.js b/create-a-container/routers/api/v1/users.js index f0bc3ad5..d4cefa61 100644 --- a/create-a-container/routers/api/v1/users.js +++ b/create-a-container/routers/api/v1/users.js @@ -5,7 +5,6 @@ const express = require('express'); const { User, Group, InviteToken, Setting } = require('../../../models'); const { sendInviteEmail, sendBulkEmail } = require('../../../utils/email'); -const { sendPushNotificationInvite } = require('../../../utils/push-notification-invite'); const { apiAuth, apiAdmin, asyncHandler, ok, created, noContent, ApiError } = require('../../../middlewares/api'); @@ -89,7 +88,6 @@ router.put( const { uid, givenName, sn, mail, userPassword, status, groupIds } = req.body || {}; const trimmedGiven = (givenName || '').trim(); const trimmedSn = (sn || '').trim(); - const previousStatus = user.status; user.uid = uid ?? user.uid; user.givenName = trimmedGiven || user.givenName; @@ -103,18 +101,11 @@ router.put( } await user.save(); - let twoFactorWarning; - if (previousStatus !== 'active' && user.status === 'active') { - const inviteResult = await sendPushNotificationInvite(user); - if (inviteResult && !inviteResult.success) { - twoFactorWarning = inviteResult.error; - } - } if (Array.isArray(groupIds)) { const groups = await Group.findAll({ where: { gidNumber: groupIds } }); await user.setGroups(groups); } - return ok(res, { ...serialize(user), ...(twoFactorWarning ? { twoFactorWarning } : {}) }); + return ok(res, serialize(user)); }), ); diff --git a/create-a-container/utils/push-notification-invite.js b/create-a-container/utils/push-notification-invite.js deleted file mode 100644 index 77909b6b..00000000 --- a/create-a-container/utils/push-notification-invite.js +++ /dev/null @@ -1,58 +0,0 @@ -const { Setting } = require('../models'); - -/** - * Send a 2FA invite request to the push notification service. - * Returns null when URL/API key are not configured (caller should skip silently). - * @param {Object} user - User data with mail, uid, givenName, sn fields - * @returns {Promise<{success: boolean, inviteUrl?: string, error?: string}|null>} - */ -async function sendPushNotificationInvite(user) { - const settings = await Setting.getMultiple([ - 'push_notification_url', - 'push_notification_api_key' - ]); - - const url = settings.push_notification_url?.trim(); - const apiKey = settings.push_notification_api_key?.trim(); - - if (!url || !apiKey) { - return null; - } - - try { - const response = await fetch(`${url}/api/invite`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: user.mail, - username: user.uid, - firstName: user.givenName, - lastName: user.sn - }) - }); - - let body; - try { - body = await response.json(); - } catch { - body = null; - } - - if (response.status === 201 && body?.success) { - return { - success: true, - inviteUrl: body.inviteUrl - }; - } - - const errorMessage = body?.error || `2FA invite failed (HTTP ${response.status})`; - return { success: false, error: errorMessage }; - } catch (err) { - return { success: false, error: '2FA invite service unreachable' }; - } -} - -module.exports = { sendPushNotificationInvite }; From 3de9a3de886ef460ffbb44e55453987451d8458c Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 4 Jun 2026 11:48:20 -0400 Subject: [PATCH 5/8] feat(client): OIDC auto-redirect login and remove 2FA UI - LoginPage auto-redirects to /auth/oidc/login when oidcEnabled, shows a friendly SSO error/retry screen on ?oidc_error, and only renders the password form when OIDC is off. - Remove all push-notification 2FA UI: challenge polling, QR enrollment on the register pages, the MFA Admin sidebar link, the twoFactorWarning toast, and the push-notification settings section. - Update auth/types models: add oidcEnabled to ServerInfo; drop pushNotificationUrl, twoFactorWarning, and challenge types. --- create-a-container/client/src/app/Sidebar.tsx | 14 - create-a-container/client/src/lib/auth.ts | 28 +- create-a-container/client/src/lib/types.ts | 4 - .../client/src/pages/auth/LoginPage.tsx | 279 +++++------------- .../client/src/pages/auth/RegisterPage.tsx | 3 - .../src/pages/auth/RegisterSuccessPage.tsx | 74 ----- .../src/pages/settings/SettingsPage.tsx | 37 +-- .../client/src/pages/users/UserFormPage.tsx | 8 +- 8 files changed, 85 insertions(+), 362 deletions(-) diff --git a/create-a-container/client/src/app/Sidebar.tsx b/create-a-container/client/src/app/Sidebar.tsx index b69533eb..3c1022b7 100644 --- a/create-a-container/client/src/app/Sidebar.tsx +++ b/create-a-container/client/src/app/Sidebar.tsx @@ -16,12 +16,10 @@ import { Box, Building2, Container as ContainerIcon, - ExternalLink, Globe, KeyRound, Server, Settings, - ShieldCheck, Users, UsersRound, } from 'lucide-react'; @@ -68,8 +66,6 @@ export function AppSidebar() { const { data: session } = useSession(); const { isCollapsed, isMobileViewport } = useSidebar(); const isAdmin = !!session?.isAdmin; - const mfaAdminUrl = - isAdmin && session?.pushNotificationUrl ? `${session.pushNotificationUrl}/admin` : null; const siteMatch = location.pathname.match(/^\/sites\/(\d+)(?:\/|$)/); const urlSiteId = siteMatch ? siteMatch[1] : null; @@ -188,16 +184,6 @@ export function AppSidebar() { )} {ADMIN.filter((l) => !l.adminOnly || isAdmin).map(renderLink)} - {mfaAdminUrl && ( - } - badge={compact ? undefined : diff --git a/create-a-container/client/src/lib/auth.ts b/create-a-container/client/src/lib/auth.ts index 8f3dcd19..9ecd01f9 100644 --- a/create-a-container/client/src/lib/auth.ts +++ b/create-a-container/client/src/lib/auth.ts @@ -4,13 +4,13 @@ import { api, ApiError, clearCsrfToken } from './api'; export interface SessionUser { user: string; isAdmin: boolean; - /** Configured push-notification service URL (admins only, empty if unset). */ - pushNotificationUrl?: string; } export interface ServerInfo { status: string; isDev: boolean; + /** True when an OIDC identity provider is configured for SSO. */ + oidcEnabled: boolean; } export const sessionKey = ['session'] as const; @@ -47,15 +47,12 @@ export interface LoginInput { } export type LoginResult = - | { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string } - | { kind: '2fa'; challengeId: string }; + | { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string }; interface LoginResponse { user?: string; isAdmin?: boolean; redirect?: string; - challengeId?: string; - requires2FA?: boolean; } export function useLoginMutation() { @@ -63,9 +60,6 @@ export function useLoginMutation() { return useMutation({ mutationFn: async (input) => { const data = await api.post('/api/v1/auth/login', input); - if (data.requires2FA && data.challengeId) { - return { kind: '2fa', challengeId: data.challengeId }; - } return { kind: 'logged-in', user: data.user || input.username, @@ -84,27 +78,13 @@ export function useLoginMutation() { isAdmin: result.isAdmin, }); // Refetch from the server so the cached session reflects the - // authoritative state (including pushNotificationUrl) before the - // caller navigates into a guarded route. + // authoritative state before the caller navigates into a guarded route. await qc.refetchQueries({ queryKey: sessionKey }); } }, }); } -export interface ChallengeStatus { - status: 'pending' | 'approved' | 'rejected' | 'timeout' | 'failed' | 'unregistered'; - user?: string; - isAdmin?: boolean; - redirect?: string; - message?: string; - registrationUrl?: string; -} - -export async function fetchChallenge(id: string): Promise { - return api.get(`/api/v1/auth/login/challenge/${encodeURIComponent(id)}`); -} - export function useLogoutMutation() { const qc = useQueryClient(); return useMutation({ diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index b9f9c0df..fc4ce818 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -136,7 +136,6 @@ export interface User { status: 'pending' | 'active' | 'disabled'; groups?: { gidNumber: number; cn: string; isAdmin: boolean }[]; isAdmin: boolean; - twoFactorWarning?: string; } export interface Group { @@ -161,9 +160,6 @@ export interface ApiKeyCreated extends ApiKey { } export interface AppSettings { - pushNotificationUrl: string; - pushNotificationEnabled: boolean; - pushNotificationApiKey: string; smtpUrl: string; smtpNoreplyAddress: string; defaultContainerEnvVars: { key: string; value: string; description?: string }[]; diff --git a/create-a-container/client/src/pages/auth/LoginPage.tsx b/create-a-container/client/src/pages/auth/LoginPage.tsx index 177d9ecd..8cf577df 100644 --- a/create-a-container/client/src/pages/auth/LoginPage.tsx +++ b/create-a-container/client/src/pages/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -10,21 +10,10 @@ import { Button, Input, Spinner, - usePrefersReducedMotion, } from '@mieweb/ui'; -import { - ShieldCheck, - Smartphone, - AlertTriangle, - XCircle, - Eye, - EyeOff, - Lock, -} from 'lucide-react'; -import { useLoginMutation, useDevLoginMutation, useServerInfo, useSession, fetchChallenge, sessionKey, type ChallengeStatus, type SessionUser } from '@/lib/auth'; -import { ApiError, clearCsrfToken } from '@/lib/api'; +import { ShieldCheck, Eye, EyeOff, Lock } from 'lucide-react'; +import { useLoginMutation, useDevLoginMutation, useServerInfo, useSession } from '@/lib/auth'; import { useDocumentTitle } from '@/lib/useDocumentTitle'; -import { useQueryClient } from '@tanstack/react-query'; const schema = z.object({ username: z.string().min(1, 'Username is required'), @@ -32,8 +21,15 @@ const schema = z.object({ }); type FormData = z.infer; -const POLL_INTERVAL_MS = 2000; -const POLL_MAX_MS = 5 * 60 * 1000; +// Human-readable messages for OIDC callback failures surfaced via ?oidc_error. +const OIDC_ERROR_MESSAGES: Record = { + expired: 'Your sign-in session expired before it completed. Please try again.', + exchange_failed: 'We could not complete sign-in with your identity provider. Please try again.', + provisioning_failed: 'Sign-in succeeded but your account could not be prepared. Contact an administrator.', + no_account: 'No matching account was found for your identity. Contact an administrator for access.', + missing_email: 'Your identity provider did not share an email address, which is required to sign in.', + account_inactive: 'Your account is not active. Contact an administrator.', +}; // A redirect target is "external" when it parses as an absolute http(s) URL. // react-router's navigate() treats such strings as in-app paths and mangles @@ -56,22 +52,18 @@ function asExternalUrl(target: string): string | null { export function LoginPage() { useDocumentTitle('Sign in'); const navigate = useNavigate(); - const qc = useQueryClient(); const [params] = useSearchParams(); const redirect = params.get('redirect') || '/'; + const oidcError = params.get('oidc_error'); const login = useLoginMutation(); const devLogin = useDevLoginMutation(); - const { data: serverInfo } = useServerInfo(); + const { data: serverInfo, isLoading: serverInfoLoading } = useServerInfo(); const isDev = !!serverInfo?.isDev; + const oidcEnabled = !!serverInfo?.oidcEnabled; const { data: session, isLoading: sessionLoading } = useSession(); - const [challengeId, setChallengeId] = useState(null); - const [challenge, setChallenge] = useState(null); const [showPassword, setShowPassword] = useState(false); const [capsLock, setCapsLock] = useState(false); - const pollTimer = useRef(null); - const pollStart = useRef(0); - const approvedHandled = useRef(false); const { register, @@ -91,92 +83,31 @@ export function LoginPage() { } }; - useEffect(() => { - return () => { - if (pollTimer.current) window.clearTimeout(pollTimer.current); - }; - }, []); - // Already-authenticated users shouldn't see the login form: send them to - // their intended destination (or home). Guarded by !challengeId so we don't - // pre-empt an in-progress 2FA flow on this page. + // their intended destination (or home). useEffect(() => { - if (!sessionLoading && session && !challengeId) { + if (!sessionLoading && session) { goTo(redirect); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session, sessionLoading, challengeId, redirect]); + }, [session, sessionLoading, redirect]); - function startPolling(id: string) { - pollStart.current = Date.now(); - approvedHandled.current = false; - const poll = async () => { - try { - const status = await fetchChallenge(id); - if (status.status === 'approved') { - // The challenge is single-use: the server activates the session and - // deletes the challenge on the first 'approved' response. Guard so a - // second in-flight poll can't re-run this (a repeat fetch would 404 - // and surface as a spurious failure). - if (approvedHandled.current) return; - approvedHandled.current = true; - // New authenticated session: drop any pre-login CSRF token so the - // next mutation fetches one bound to it, avoiding a reactive 403. - clearCsrfToken(); - // The server has already saved the session, so seed the cache as the - // authoritative state. Navigate immediately — RequireAuth reads this - // cached session and lets us through. We intentionally do NOT block - // navigation on a refetch: a transient refetch failure/race must not - // bounce the now-authenticated user back to the login screen. - qc.setQueryData(sessionKey, { - user: status.user || '', - isAdmin: !!status.isAdmin, - }); - void qc.invalidateQueries({ queryKey: sessionKey }); - goTo(status.redirect && status.redirect !== '/' ? status.redirect : redirect); - return; - } - // Only surface non-approved statuses (keeps an 'approved' status from - // ever rendering through the error/fallback view). - setChallenge(status); - if ( - status.status === 'rejected' || - status.status === 'timeout' || - status.status === 'failed' || - status.status === 'unregistered' - ) { - return; - } - if (Date.now() - pollStart.current > POLL_MAX_MS) { - setChallenge({ status: 'timeout', message: 'Challenge expired' }); - return; - } - pollTimer.current = window.setTimeout(poll, POLL_INTERVAL_MS); - } catch (err) { - // If we've already handled approval and navigated, ignore late errors - // from any straggling poll (e.g. a 404 for the now-deleted challenge). - if (approvedHandled.current) return; - setChallenge({ - status: 'failed', - message: err instanceof ApiError ? err.message : 'Failed to check challenge', - }); - } - }; - poll(); - } + // When an identity provider is configured, the login screen automatically + // redirects to it. We only auto-redirect once we know there's no active + // session and the previous attempt didn't fail (avoids a redirect loop). + const shouldAutoRedirectToIdp = + oidcEnabled && !oidcError && !sessionLoading && !session; + useEffect(() => { + if (shouldAutoRedirectToIdp) { + const url = `/api/v1/auth/oidc/login?redirect=${encodeURIComponent(redirect)}`; + window.location.assign(url); + } + }, [shouldAutoRedirectToIdp, redirect]); const onSubmit = handleSubmit(async (values) => { - setChallenge(null); - setChallengeId(null); try { const result = await login.mutateAsync({ ...values, redirect }); - if (result.kind === 'logged-in') { - goTo(result.redirect && result.redirect !== '/' ? result.redirect : redirect); - } else { - setChallengeId(result.challengeId); - setChallenge({ status: 'pending' }); - startPolling(result.challengeId); - } + goTo(result.redirect && result.redirect !== '/' ? result.redirect : redirect); } catch { /* error handled via login.error */ } @@ -198,13 +129,9 @@ export function LoginPage() { ? 'Invalid username or password' : null; - if (challengeId && challenge) { - return setChallengeId(null)} />; - } - - // Avoid flashing the form while we resolve the session / redirect an - // already-authenticated user. - if (sessionLoading || (session && !challengeId)) { + // Avoid flashing the form while we resolve the session / server info, or + // while we hand off to the identity provider. + if (sessionLoading || serverInfoLoading || (session && !sessionLoading) || shouldAutoRedirectToIdp) { return (
@@ -212,6 +139,49 @@ export function LoginPage() { ); } + // OIDC is configured but we landed back here with an error (or after a + // failed attempt). Internal password login is disabled, so offer a retry. + if (oidcEnabled) { + return ( +
+
+

+ Sign in +

+

+ This site uses single sign-on through your identity provider. +

+
+ + {oidcError && ( + + Sign in failed + + {OIDC_ERROR_MESSAGES[oidcError] || 'Sign-in could not be completed. Please try again.'} + + + )} + + +
+ ); + } + const passwordField = register('password'); return ( @@ -361,104 +331,9 @@ export function LoginPage() {

- Protected by push-approved sign-in.{' '} + Secured by your organization’s sign-in policy.{' '}

); } - -function ChallengeStatusView({ - status, - onCancel, -}: { - status: ChallengeStatus; - onCancel: () => void; -}) { - const reduceMotion = usePrefersReducedMotion(); - if (status.status === 'pending') { - return ( -
-
- {!reduceMotion && ( -
- -
-

- Approve sign-in on your device -

-

- We sent a push notification to your registered device. Tap{' '} - Approve to - finish signing in. -

-
- -
- - Waiting for approval… -
- - -
- ); - } - - if (status.status === 'unregistered') { - return ( -
-
- - - -

- Device not registered -

-

- No device is enrolled for push 2FA on this account. Contact an administrator to receive - an enrollment invite. -

-
- -
- ); - } - - return ( -
-
- - - -

- Sign-in not completed -

-

- {status.message || `Status: ${status.status}`} -

-
- -
- ); -} diff --git a/create-a-container/client/src/pages/auth/RegisterPage.tsx b/create-a-container/client/src/pages/auth/RegisterPage.tsx index 7c4ffee4..454ccfcc 100644 --- a/create-a-container/client/src/pages/auth/RegisterPage.tsx +++ b/create-a-container/client/src/pages/auth/RegisterPage.tsx @@ -30,7 +30,6 @@ interface RegisterResponse { uid: string; status: 'active' | 'pending'; message: string; - twoFactor?: { enrollmentToken?: string; warning?: string }; } export function RegisterPage() { @@ -86,8 +85,6 @@ export function RegisterPage() { uid: res.uid, status: res.status, message: res.message, - enrollmentToken: res.twoFactor?.enrollmentToken, - warning: res.twoFactor?.warning, }, }); } catch (err) { diff --git a/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx b/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx index 05ab2502..7d2b1d7a 100644 --- a/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx +++ b/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx @@ -1,45 +1,16 @@ -import { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router'; -import { Alert, AlertDescription, AlertTitle, Spinner } from '@mieweb/ui'; -import { api, ApiError } from '@/lib/api'; import { useDocumentTitle } from '@/lib/useDocumentTitle'; interface RegisterState { uid?: string; status?: 'active' | 'pending'; message?: string; - enrollmentToken?: string; - warning?: string; } export function RegisterSuccessPage() { useDocumentTitle('Account created'); const location = useLocation(); const state = (location.state as RegisterState | null) || {}; - const [qr, setQr] = useState<{ qrCodeDataUri: string; inviteUrl: string } | null>(null); - const [qrError, setQrError] = useState(null); - const [qrLoading, setQrLoading] = useState(false); - - useEffect(() => { - if (!state.enrollmentToken) return; - let cancelled = false; - setQrLoading(true); - (async () => { - try { - const data = await api.get<{ qrCodeDataUri: string; inviteUrl: string }>( - `/api/v1/auth/register/2fa-qr/${encodeURIComponent(state.enrollmentToken!)}`, - ); - if (!cancelled) setQr(data); - } catch (err) { - if (!cancelled) setQrError(err instanceof ApiError ? err.message : 'QR code unavailable'); - } finally { - if (!cancelled) setQrLoading(false); - } - })(); - return () => { - cancelled = true; - }; - }, [state.enrollmentToken]); return (
@@ -55,51 +26,6 @@ export function RegisterSuccessPage() {

- {state.warning && ( - - Notice - {state.warning} - - )} - - {state.enrollmentToken && ( -
-

- Enroll your second factor -

-

- Scan this QR code with the push-notification app to register your device for 2FA. -

- {qrLoading && ( -
- -
- )} - {qrError && ( - - {qrError} - - )} - {qr && ( - - )} -
- )} - !v.pushNotificationEnabled || v.pushNotificationUrl.trim() !== '', - { path: ['pushNotificationUrl'], message: 'URL is required when push notifications are enabled' }, -); +}); type FormData = z.infer; export function SettingsPage() { @@ -42,19 +35,15 @@ export function SettingsPage() { const toast = useToast(); const { data, isLoading, error } = useQuery({ queryKey: keys.settings(), queryFn: queries.getSettings }); - const { register, handleSubmit, reset, control, watch, setValue, formState } = useForm({ + const { register, handleSubmit, reset, control } = useForm({ resolver: zodResolver(schema), defaultValues: { - pushNotificationEnabled: false, - pushNotificationUrl: '', - pushNotificationApiKey: '', smtpUrl: '', smtpNoreplyAddress: '', defaultContainerEnvVars: [], }, }); const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); - const pushEnabled = watch('pushNotificationEnabled'); useEffect(() => { if (data) reset(data); @@ -76,28 +65,6 @@ export function SettingsPage() {
} bordered />
mutation.mutate(v))} className="grid max-w-3xl gap-8"> -
-

Push notifications

- setValue('pushNotificationEnabled', c)} - /> - - -
-

SMTP

(`/api/v1/users/${uid}`, payload) : api.post('/api/v1/users', payload); }, - onSuccess: (result) => { - if (result.twoFactorWarning) { - toast.warning(`User saved, but 2FA invite failed: ${result.twoFactorWarning}`); - } else { - toast.success(isEdit ? 'User updated' : 'User created'); - } + onSuccess: () => { + toast.success(isEdit ? 'User updated' : 'User created'); qc.invalidateQueries({ queryKey: keys.users() }); navigate('/users'); }, From de7163a9788f583d52d69f1a5d7dcf8a46318eab Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 4 Jun 2026 11:48:26 -0400 Subject: [PATCH 6/8] docs: document OIDC env vars and update API spec - example.env: document OIDC_ISSUER_URL, client credentials, redirect URI, scopes, JIT provisioning, and post-logout redirect. - openapi.v1.yaml: add /auth/oidc/login and /auth/oidc/callback, note the 403 on /auth/login when OIDC is enabled, and remove the 2FA challenge and 2fa-qr endpoints. --- create-a-container/example.env | 25 ++++++++++++++++++++++- create-a-container/openapi.v1.yaml | 32 +++++++++++++++++------------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/create-a-container/example.env b/create-a-container/example.env index c74aca9a..d113e486 100644 --- a/create-a-container/example.env +++ b/create-a-container/example.env @@ -21,4 +21,27 @@ POSTGRES_DATABASE= # path to the morgan access log file. if unset, access logs go to stdout. # if set, the file is opened in append mode. -ACCESS_LOG= \ No newline at end of file +ACCESS_LOG= + +# --- OIDC / single sign-on (optional) --- +# SSO is enabled only when OIDC_ISSUER_URL, OIDC_CLIENT_ID, and +# OIDC_CLIENT_SECRET are all set. When enabled, the login page redirects to the +# identity provider and internal password login + self-registration are +# disabled. To recover from a misconfiguration, unset these vars and restart. + +# Discovery base URL of the identity provider (required to enable OIDC) +OIDC_ISSUER_URL= +# OAuth2 client credentials issued by the IdP (required to enable OIDC) +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +# Absolute callback URL registered with the IdP. If unset, it is derived from +# the request as `${protocol}://${host}/api/v1/auth/oidc/callback`. +OIDC_REDIRECT_URI= +# Space-separated scopes requested from the IdP (default: openid profile email) +OIDC_SCOPES= +# Set to "true" to auto-create (just-in-time provision) users on first login. +# When false, only users that already exist (matched by subject or email) can +# sign in. +OIDC_JIT_PROVISION= +# Optional RP-initiated logout return URL. +OIDC_POST_LOGOUT_REDIRECT_URI= \ No newline at end of file diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index fb985ce0..e049dd16 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -169,7 +169,7 @@ paths: /auth/login: post: tags: [Auth] - summary: Username/password login (may require 2FA) + summary: Username/password login (disabled when OIDC SSO is enabled) security: [] requestBody: required: true @@ -183,20 +183,30 @@ paths: password: { type: string, format: password } redirect: { type: string } responses: - '200': { description: Logged in OR 2FA challenge issued } + '200': { description: Logged in } '401': { description: Invalid credentials } - /auth/login/challenge/{id}: + '403': { description: 'OIDC enabled (code: oidc_enabled) — use SSO instead' } + /auth/oidc/login: get: tags: [Auth] - summary: Poll 2FA challenge status + summary: Begin OIDC single sign-on (redirects to the identity provider) security: [] parameters: - - in: path - name: id - required: true + - in: query + name: redirect + required: false schema: { type: string } responses: - '200': { description: status -> pending|approved|rejected|timeout|failed|unregistered } + '302': { description: Redirect to the identity provider authorization endpoint } + '404': { description: 'OIDC not configured (code: oidc_disabled)' } + /auth/oidc/callback: + get: + tags: [Auth] + summary: OIDC authorization-code callback (completes SSO and starts a session) + security: [] + responses: + '302': { description: 'Redirect to the post-login destination on success, or /login?oidc_error= on failure' } + '404': { description: 'OIDC not configured (code: oidc_disabled)' } /auth/logout: post: tags: [Auth] @@ -227,12 +237,6 @@ paths: security: [] parameters: [{ in: path, name: token, required: true, schema: { type: string } }] responses: { '200': { description: Invite valid, returns email } } - /auth/register/2fa-qr/{token}: - get: - tags: [Auth] - security: [] - parameters: [{ in: path, name: token, required: true, schema: { type: string } }] - responses: { '200': { description: QR code data URI for push-notification enrollment } } /auth/password-reset/request: post: tags: [Auth] From c39ebc55331011595962c81d5a2f2b1b0a225e21 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 5 Jun 2026 14:56:58 -0400 Subject: [PATCH 7/8] feat(auth): RP-initiated logout for OIDC SSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing out only cleared the local Manager session, leaving the IdP session alive. The login page then auto-redirected to the IdP, which silently re-issued a login — so users could never actually sign out. - utils/oidc: add buildEndSessionUrl() to construct the IdP end-session URL (id_token_hint + post_logout_redirect_uri); capture the raw id_token from the callback for use as the hint. - auth router: store the id_token on the session at login; logout now returns a `logoutUrl` to the IdP end-session endpoint when OIDC is enabled, defaulting the post-logout target to /login?logged_out=1. Falls back to local-only logout if the IdP has no end-session endpoint. - client: redirect the browser to `logoutUrl` immediately after the logout POST resolves, before clearing the query cache, so the IdP navigation isn't beaten by the SPA's re-render -> /login -> SSO auto-redirect. Suppress that auto-redirect when ?logged_out=1 is present and show a "Signed out" confirmation. - example.env: document OIDC_POST_LOGOUT_REDIRECT_URI default/behavior. --- create-a-container/client/src/lib/auth.ts | 24 +++++++++++-- .../client/src/pages/auth/LoginPage.tsx | 18 ++++++++-- create-a-container/example.env | 4 ++- create-a-container/routers/api/v1/auth.js | 34 ++++++++++++++++++- create-a-container/utils/oidc.js | 30 ++++++++++++++++ 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/create-a-container/client/src/lib/auth.ts b/create-a-container/client/src/lib/auth.ts index 9ecd01f9..9f846072 100644 --- a/create-a-container/client/src/lib/auth.ts +++ b/create-a-container/client/src/lib/auth.ts @@ -89,9 +89,29 @@ export function useLogoutMutation() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { - await api.post('/api/v1/auth/logout'); + // When OIDC SSO is enabled the server returns a `logoutUrl` pointing at + // the IdP's end-session endpoint. We must visit it to terminate the IdP + // session; otherwise the live IdP session signs the user straight back in. + const data = await api.post<{ loggedOut: boolean; logoutUrl?: string | null }>( + '/api/v1/auth/logout', + ); + + // If we have an IdP logout URL, hand off to the browser *before* touching + // the query cache. Clearing the cache here would synchronously re-render + // guarded views and bounce the user to /login, whose own effect kicks off + // a fresh SSO redirect — racing (and beating) this navigation. Assigning + // first makes RP-initiated logout the only navigation that happens. + if (data?.logoutUrl) { + window.location.assign(data.logoutUrl); + // Block further React work this tick; the page is being replaced. + await new Promise(() => {}); + } + + return data; }, - onSettled: () => { + onSettled: (data) => { + // Reached only for the local-only logout path (no IdP end-session URL). + if (data?.logoutUrl) return; clearCsrfToken(); qc.setQueryData(sessionKey, null); qc.clear(); diff --git a/create-a-container/client/src/pages/auth/LoginPage.tsx b/create-a-container/client/src/pages/auth/LoginPage.tsx index 8cf577df..50dd9e62 100644 --- a/create-a-container/client/src/pages/auth/LoginPage.tsx +++ b/create-a-container/client/src/pages/auth/LoginPage.tsx @@ -55,6 +55,10 @@ export function LoginPage() { const [params] = useSearchParams(); const redirect = params.get('redirect') || '/'; const oidcError = params.get('oidc_error'); + // Set when the user just signed out (locally, or via the IdP's post-logout + // redirect). Suppresses the automatic SSO redirect so logout doesn't loop + // straight back into a new sign-in. + const loggedOut = params.get('logged_out') !== null; const login = useLoginMutation(); const devLogin = useDevLoginMutation(); const { data: serverInfo, isLoading: serverInfoLoading } = useServerInfo(); @@ -94,9 +98,10 @@ export function LoginPage() { // When an identity provider is configured, the login screen automatically // redirects to it. We only auto-redirect once we know there's no active - // session and the previous attempt didn't fail (avoids a redirect loop). + // session and the previous attempt didn't fail (avoids a redirect loop), and + // not immediately after an explicit logout (so sign-out doesn't loop back in). const shouldAutoRedirectToIdp = - oidcEnabled && !oidcError && !sessionLoading && !session; + oidcEnabled && !oidcError && !loggedOut && !sessionLoading && !session; useEffect(() => { if (shouldAutoRedirectToIdp) { const url = `/api/v1/auth/oidc/login?redirect=${encodeURIComponent(redirect)}`; @@ -165,6 +170,15 @@ export function LoginPage() { )} + {!oidcError && loggedOut && ( + + Signed out + + You have been signed out. Use single sign-on to sign back in. + + + )} +