From 12fe79fc85cf44f3723424b70a9e2d76d1c32b11 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 10 Apr 2026 16:28:26 -0400 Subject: [PATCH] fix(deps): migrate SSO OIDC code to openid-client v6 (#492) * chore(deps): bump the all-npm-backend group across 1 directory with 4 updates Bumps the all-npm-backend group with 4 updates in the /backend directory: [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr), [openid-client](https://github.com/panva/openid-client), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). Updates `@aws-sdk/client-ecr` from 3.1026.0 to 3.1028.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1028.0/clients/client-ecr) Updates `openid-client` from 5.7.1 to 6.8.2 - [Release notes](https://github.com/panva/openid-client/releases) - [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/panva/openid-client/compare/v5.7.1...v6.8.2) Updates `@types/node` from 25.5.2 to 25.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `vitest` from 4.1.3 to 4.1.4 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest) --- updated-dependencies: - dependency-name: "@aws-sdk/client-ecr" dependency-version: 3.1028.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: openid-client dependency-version: 6.8.2 dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-npm-backend - dependency-name: "@types/node" dependency-version: 25.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: vitest dependency-version: 4.1.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend ... Signed-off-by: dependabot[bot] * fix(deps): migrate SSO OIDC code to openid-client v6 The v5 Issuer/Client/generators API was removed upstream. Rewrite the OIDC auth flow in SSOService to use the v6 functional API: * discovery()/Configuration replace Issuer.discover + new issuer.Client * buildAuthorizationUrl replaces client.authorizationUrl * authorizationCodeGrant replaces client.callback, and handles the state check internally so the manual CSRF guard is removed * fetchUserInfo replaces client.userinfo and now receives claims.sub so v6 can reject userinfo/id_token subject mismatches Also: * Cache the discovered Configuration per provider in CacheService (TTL 5 min) so a single login flow does not pay the HTTPS discovery round trip twice. Invalidation wired into saveProviderConfig, deleteProviderConfig, and seedOidcFromEnv via a private helper. * Fix the testOidcDiscovery hack that passed a "discovery-probe" placeholder when clientId was missing; validate clientId upfront instead and return a clear error. * Log fetchUserInfo failures at warn level before falling back to id_token claims so a subject-mismatch rejection is not silently hidden. Unblocks dependabot PR #470. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 + backend/package-lock.json | 173 ++++++++++++----------------- backend/package.json | 2 +- backend/src/services/SSOService.ts | 157 ++++++++++++++++---------- 4 files changed, 175 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7891cd9..5fd27ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **deps:** migrate SSO OIDC integration from `openid-client` v5 to v6. The v5 `Issuer` / `Client` / `generators` API was removed upstream; the service now uses `Configuration`, `discovery`, `buildAuthorizationUrl`, `authorizationCodeGrant`, and `fetchUserInfo`. Discovery metadata is cached for 5 minutes via `CacheService` so a single login flow does not pay the HTTPS round trip twice, and the cache is invalidated on every SSO config write. `fetchUserInfo` failures now log the underlying error instead of silently falling back to id_token claims. + ## [0.43.0](https://github.com/AnsoCode/Sencho/compare/v0.42.7...v0.43.0) (2026-04-10) diff --git a/backend/package-lock.json b/backend/package-lock.json index 399e0ac8..9f9ba7a5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,7 +35,7 @@ "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", "node-pty": "^1.1.0", - "openid-client": "^5.7.1", + "openid-client": "^6.8.2", "semver": "^7.7.4", "systeminformation": "^5.31.1", "ws": "^8.19.0", @@ -187,9 +187,9 @@ } }, "node_modules/@aws-sdk/client-ecr": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1026.0.tgz", - "integrity": "sha512-w9goy0kMoOeAT2HcZEpczW0kCrv2EcnS5qyehd6rU+OlbdjfY42gOKdUOy02RvS2SUqycQnecKgU7Q09gHdKuA==", + "version": "3.1028.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1028.0.tgz", + "integrity": "sha512-NvnqmQxFtBC6eQKW5leLIfE8Go7GgBlIiFb9sfCVEMXg4i9rYAlb/Sd4reiH0AdWY/L2p+fCqOsG7Dw/DVwCoQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -2168,12 +2168,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/qs": { @@ -2512,16 +2512,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", - "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2530,13 +2530,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", - "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.3", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2557,9 +2557,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", - "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -2570,13 +2570,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", - "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.3", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -2584,14 +2584,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", - "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2600,9 +2600,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", - "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -2610,13 +2610,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", - "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4588,9 +4588,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -5044,18 +5044,6 @@ "loose-envify": "cli.js" } }, - "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/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -5357,6 +5345,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5366,15 +5363,6 @@ "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", @@ -5398,15 +5386,6 @@ ], "license": "MIT" }, - "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", @@ -5438,15 +5417,13 @@ } }, "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==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", "license": "MIT", "dependencies": { - "jose": "^4.15.9", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" }, "funding": { "url": "https://github.com/sponsors/panva" @@ -6606,9 +6583,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/unpipe": { @@ -6757,19 +6734,19 @@ } }, "node_modules/vitest": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", - "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.3", - "@vitest/mocker": "4.1.3", - "@vitest/pretty-format": "4.1.3", - "@vitest/runner": "4.1.3", - "@vitest/snapshot": "4.1.3", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6797,12 +6774,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.3", - "@vitest/browser-preview": "4.1.3", - "@vitest/browser-webdriverio": "4.1.3", - "@vitest/coverage-istanbul": "4.1.3", - "@vitest/coverage-v8": "4.1.3", - "@vitest/ui": "4.1.3", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6955,12 +6932,6 @@ "node": ">=10" } }, - "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/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/backend/package.json b/backend/package.json index c8e54f06..650f4794 100644 --- a/backend/package.json +++ b/backend/package.json @@ -61,7 +61,7 @@ "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", "node-pty": "^1.1.0", - "openid-client": "^5.7.1", + "openid-client": "^6.8.2", "semver": "^7.7.4", "systeminformation": "^5.31.1", "ws": "^8.19.0", diff --git a/backend/src/services/SSOService.ts b/backend/src/services/SSOService.ts index d22dcf48..9d41d0b8 100644 --- a/backend/src/services/SSOService.ts +++ b/backend/src/services/SSOService.ts @@ -1,9 +1,25 @@ import crypto from 'crypto'; import { Client as LdapClient } from 'ldapts'; -import { Issuer, Client as OIDCClient, generators } from 'openid-client'; +import { + Configuration, + discovery, + buildAuthorizationUrl, + authorizationCodeGrant, + fetchUserInfo, + randomState, + randomPKCECodeVerifier, + calculatePKCECodeChallenge, +} from 'openid-client'; import { DatabaseService, User, AuthProvider } from './DatabaseService'; import { CryptoService } from './CryptoService'; import { LicenseService } from './LicenseService'; +import { CacheService } from './CacheService'; + +// OIDC discovery metadata changes rarely; caching it eliminates the redundant +// HTTPS round-trip between getOIDCAuthorizationUrl and handleOIDCCallback in +// the same login flow, and across back-to-back logins. +const OIDC_CONFIG_TTL_MS = 5 * 60 * 1000; +const OIDC_CONFIG_NS = 'oidc-config'; export interface SSOProviderConfig { provider: string; @@ -121,6 +137,11 @@ export class SSOService { configForStorage.oidcClientSecret = cryptoSvc.encrypt(configForStorage.oidcClientSecret); } db.upsertSSOConfig(provider, true, JSON.stringify(configForStorage)); + this.invalidateOIDCConfigCache(provider); + } + + private invalidateOIDCConfigCache(provider: string): void { + CacheService.getInstance().invalidate(`${OIDC_CONFIG_NS}:${provider}`); } // --- Config Management --- @@ -173,10 +194,13 @@ export class SSOService { config.enabled, JSON.stringify(configForStorage) ); + // Client ID, secret, or issuer URL may have changed, so drop the cached Configuration. + this.invalidateOIDCConfigCache(config.provider); } public deleteProviderConfig(provider: string): void { DatabaseService.getInstance().deleteSSOConfig(provider); + this.invalidateOIDCConfigCache(provider); } // --- LDAP Authentication --- @@ -313,21 +337,22 @@ export class SSOService { throw new Error(`SSO provider ${provider} is missing client ID`); } - const { client } = await this.getOIDCClient(provider, config, callbackUrl); - const state = generators.state(); - const codeVerifier = generators.codeVerifier(); - const codeChallenge = generators.codeChallenge(codeVerifier); + const oidcConfig = await this.getOIDCConfig(provider, config); + const state = randomState(); + const codeVerifier = randomPKCECodeVerifier(); + const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); const scopes = config.oidcScopes || 'openid email profile'; - const url = client.authorizationUrl({ + const url = buildAuthorizationUrl(oidcConfig, { + redirect_uri: callbackUrl, scope: scopes, state, code_challenge: codeChallenge, code_challenge_method: 'S256', }); - return { url, state, codeVerifier }; + return { url: url.href, state, codeVerifier }; } public async handleOIDCCallback( @@ -337,39 +362,43 @@ export class SSOService { expectedState: string, codeVerifier: string ): Promise { - if (params.state !== expectedState) { - return { success: false, error: 'Invalid state parameter (possible CSRF attack)' }; - } - const config = this.getProviderConfigDecrypted(provider); if (!config || !config.enabled) { return { success: false, error: `SSO provider ${provider} is not configured` }; } try { - const { client } = await this.getOIDCClient(provider, config, callbackUrl); + const oidcConfig = await this.getOIDCConfig(provider, config); - const tokenSet = await client.callback(callbackUrl, { code: params.code, state: params.state }, { - state: expectedState, - code_verifier: codeVerifier, + const currentUrl = new URL(callbackUrl); + currentUrl.searchParams.set('code', params.code); + currentUrl.searchParams.set('state', params.state); + + const tokens = await authorizationCodeGrant(oidcConfig, currentUrl, { + pkceCodeVerifier: codeVerifier, + expectedState, }); let userInfo: Record; if (provider === 'oidc_github') { // GitHub doesn't support standard OIDC userinfo; use their API - userInfo = await this.fetchGitHubUserInfo(tokenSet.access_token as string); - } else if (tokenSet.id_token) { - const claims = tokenSet.claims(); - // Also fetch userinfo for complete profile - try { - const info = await client.userinfo(tokenSet.access_token as string); - userInfo = { ...claims, ...info }; - } catch { - userInfo = claims as Record; - } + userInfo = await this.fetchGitHubUserInfo(tokens.access_token); } else { - userInfo = await client.userinfo(tokenSet.access_token as string) as Record; + const claims = tokens.claims(); + if (!claims) { + return { success: false, error: 'OIDC provider did not return an ID token' }; + } + // Pass claims.sub so v6 rejects userinfo/id_token subject mismatches. + try { + const info = await fetchUserInfo(oidcConfig, tokens.access_token, String(claims.sub)); + userInfo = { ...claims, ...info }; + } catch (err) { + // Log so a subject-mismatch rejection is not silently hidden. + const message = err instanceof Error ? err.message : String(err); + console.warn(`[SSO] fetchUserInfo failed, falling back to id_token claims: ${message}`); + userInfo = { ...claims }; + } } const sub = String(userInfo.sub || userInfo.id || ''); @@ -424,38 +453,37 @@ export class SSOService { }; } - private async getOIDCClient( + private async getOIDCConfig( provider: string, config: SSOProviderConfig, - callbackUrl: string - ): Promise<{ client: OIDCClient; issuer: InstanceType }> { - let issuer: InstanceType; + ): Promise { + const clientId = config.oidcClientId || ''; + const clientSecret = config.oidcClientSecret || undefined; if (provider === 'oidc_github') { - // GitHub is not a standard OIDC provider - manually configure - issuer = new Issuer({ - issuer: 'https://github.com', - authorization_endpoint: 'https://github.com/login/oauth/authorize', - token_endpoint: 'https://github.com/login/oauth/access_token', - userinfo_endpoint: 'https://api.github.com/user', - }); - } else { - const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider]; - if (!issuerUrl) { - throw new Error(`Issuer URL not configured for ${provider}`); - } - issuer = await Issuer.discover(issuerUrl); + // GitHub is not a standard OIDC provider. Construct Configuration + // directly from known endpoints instead of going through discovery. + return new Configuration( + { + issuer: 'https://github.com', + authorization_endpoint: 'https://github.com/login/oauth/authorize', + token_endpoint: 'https://github.com/login/oauth/access_token', + userinfo_endpoint: 'https://api.github.com/user', + }, + clientId, + clientSecret, + ); } - const client = new issuer.Client({ - client_id: config.oidcClientId || '', - client_secret: config.oidcClientSecret || '', - redirect_uris: [callbackUrl], - response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', - }); - - return { client, issuer }; + const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider]; + if (!issuerUrl) { + throw new Error(`Issuer URL not configured for ${provider}`); + } + return CacheService.getInstance().getOrFetch( + `${OIDC_CONFIG_NS}:${provider}`, + OIDC_CONFIG_TTL_MS, + () => discovery(new URL(issuerUrl), clientId, clientSecret), + ); } private resolveRoleFromOidc(userInfo: Record, config: SSOProviderConfig): 'admin' | 'viewer' { @@ -568,17 +596,24 @@ export class SSOService { if (!config) { return { success: false, error: `Provider ${provider} not configured` }; } + if (provider === 'oidc_github') { + return { success: true, issuer: 'https://github.com (OAuth2, non-standard OIDC)' }; + } + const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider]; + if (!issuerUrl) { + return { success: false, error: 'Issuer URL not configured' }; + } + if (!config.oidcClientId) { + return { success: false, error: 'Client ID is required to test discovery' }; + } try { - if (provider === 'oidc_github') { - return { success: true, issuer: 'https://github.com (OAuth2, non-standard OIDC)' }; - } - const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider]; - if (!issuerUrl) { - return { success: false, error: 'Issuer URL not configured' }; - } - const issuer = await Issuer.discover(issuerUrl); - return { success: true, issuer: issuer.metadata.issuer }; + const oidcConfig = await discovery( + new URL(issuerUrl), + config.oidcClientId, + config.oidcClientSecret || undefined, + ); + return { success: true, issuer: oidcConfig.serverMetadata().issuer }; } catch (err) { const message = err instanceof Error ? err.message : 'Discovery failed'; return { success: false, error: message };