From 86b1baa9d1ae2ee99b00497926817bdc4f285896 Mon Sep 17 00:00:00 2001 From: foxriver76 Date: Wed, 14 May 2025 13:15:12 +0200 Subject: [PATCH 1/2] added SSO via keycloak --- package-lock.json | 178 +++++++++++++++++++++++++--- package.json | 2 + src/lib/oauth2-model.ts | 9 ++ src/lib/oauth2.ts | 250 ++++++++++++++++++++++++++++++++++++++-- src/lib/utils.ts | 27 +++++ 5 files changed, 444 insertions(+), 22 deletions(-) create mode 100644 src/lib/utils.ts diff --git a/package-lock.json b/package-lock.json index ea36d1e..5992e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.2.8", "license": "MIT", "dependencies": { + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", "oauth2-server": "^3.1.1" }, "devDependencies": { @@ -599,7 +601,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -610,7 +611,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -628,7 +628,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -641,7 +640,6 @@ "version": "4.19.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -654,7 +652,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -665,18 +662,30 @@ "license": "MIT", "peer": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "node_modules/@types/node": { "version": "22.15.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -704,21 +713,18 @@ "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -729,7 +735,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1395,6 +1400,11 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1876,7 +1886,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2018,6 +2027,14 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.112", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.112.tgz", @@ -3931,6 +3948,14 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4014,6 +4039,27 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -4031,6 +4077,41 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4057,6 +4138,11 @@ "node": ">= 0.8.0" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4079,6 +4165,41 @@ "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4087,6 +4208,11 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -4135,6 +4261,26 @@ "dev": true, "license": "ISC" }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4334,7 +4480,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/natural-compare": { @@ -5176,7 +5321,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5995,7 +6139,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -6334,6 +6477,11 @@ "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==" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index b0a5e3e..730ab28 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "main": "build/index.js", "types": "build/index.d.ts", "dependencies": { + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", "oauth2-server": "^3.1.1" }, "overrides_comment": "We override type-is as is conflicting with body-parser", diff --git a/src/lib/oauth2-model.ts b/src/lib/oauth2-model.ts index b20f2cd..0684f1f 100644 --- a/src/lib/oauth2-model.ts +++ b/src/lib/oauth2-model.ts @@ -11,6 +11,7 @@ import { } from 'oauth2-server'; import type { NextFunction, Request, Response } from 'express'; +import { SSO_PASSWORD } from './utils'; // We must save both tokens, as by logout we must revoke both export interface InternalStorageToken { @@ -97,6 +98,7 @@ export class OAuth2Model implements RefreshTokenModel { authorize = async (req: Request, res: Response, next: NextFunction): Promise => { const _req: Request & { user?: string } = req; + // Check if the user is logged in if (!_req.user) { // If authenticated by token in query like /blabla?token=ACCESS_TOKEN @@ -188,6 +190,13 @@ export class OAuth2Model implements RefreshTokenModel { * Get user. */ getUser = async (username: string, password: string): Promise => { + if (password === SSO_PASSWORD) { + this.adapter.log.debug(`SSO login as ${username}`); + return { + id: username, + }; + } + const now = Date.now(); if (this.bruteForce[username]?.errors > 4) { let minutes = now - this.bruteForce[username].time; diff --git a/src/lib/oauth2.ts b/src/lib/oauth2.ts index 66b53a6..dbd47aa 100644 --- a/src/lib/oauth2.ts +++ b/src/lib/oauth2.ts @@ -1,6 +1,9 @@ import type { Request, Response, Express, NextFunction } from 'express'; import OAuth2Server, { Request as OAuthRequest, Response as OAuthResponse, type Token } from 'oauth2-server'; import { type InternalStorageToken, OAuth2Model } from './oauth2-model'; +import { verify, type JwtHeader, type SigningKeyCallback, type JwtPayload } from 'jsonwebtoken'; +import { JwksClient } from 'jwks-rsa'; +import { oauthTokenToResponse, SSO_PASSWORD } from './utils'; export interface CookieOptions { /** Convenient option for setting the expiry time relative to the current time in **milliseconds**. */ @@ -27,6 +30,72 @@ export interface CookieOptions { partitioned?: boolean | undefined; } +/** The base state query attribute for SSO */ +type SsoBaseState = { + /** Url to redirect too, after SSO has been performed */ + redirectUrl: string; +}; + +/** The state query attribute for SSO */ +type SsoState = SsoBaseState & + ( + | { + /** If this is a register request */ + method: 'register'; + /** Register requests have the name of the ioBroker user to register */ + user: string; + } + | { + /** If this is a login request */ + method: 'login'; + } + ); + +/** Query parameters in the SSO callback */ +interface SsoCallbackQuery { + /** Code to exchange for token */ + code: string; + /** SsoState as parseable string */ + state: string; +} + +interface OidcTokenResponse { + access_token: string; + refresh_token: string; + token_type: 'Bearer'; + /** Used to retrieve the JwtFullPayload */ + id_token: string; + 'not-before-policy': number; + session_state: string; + scope: string; +} + +interface JwtFullPayload extends Required { + auth_time: number; + typ: string; + azp: string; + sid: string; + at_hash: string; + acr: string; + email_verified: boolean; + name: string; + preferred_username: string; + given_name: string; + family_name: string; + email: string; +} + +/** Keycloak ioBroker realm */ +const KEYCLOAK_ISSUER = 'https://keycloak.heusinger-it.duckdns.org/realms/iobroker-local'; +/** The client for local authentication */ +const KEYCLOAK_CLIENT_ID = 'iobroker-local-auth'; + +const jwksClient = new JwksClient({ + jwksUri: `${KEYCLOAK_ISSUER}/protocol/openid-connect/certs`, + cache: true, + rateLimit: true, +}); + /** * Create an OAuth2 server on the given Express app. * @@ -61,9 +130,182 @@ export function createOAuth2Server( requireClientAuthentication: { password: false, refresh_token: false }, }); + options.app.get('/sso', (req: Request, res: Response): void => { + const scope = 'openid email'; + const { redirectUrl, method } = req.query; + + let user = ''; + + if (req.query.method === 'register') { + user = req.query.user; + } + + const redirectUri = `${req.protocol}://${req.get('host')}/sso-callback`; + const authUrl = `${KEYCLOAK_ISSUER}/protocol/openid-connect/auth?client_id=${KEYCLOAK_CLIENT_ID}&response_type=code&scope=${scope}&redirect_uri=${redirectUri}&state=${encodeURIComponent(JSON.stringify({ method, redirectUrl, user }))}`; + + res.redirect(authUrl); + }); + + options.app.get('/sso-callback', async (req: Request, res): Promise => { + const { code, state } = req.query as unknown as SsoCallbackQuery; + + const thisHost = `${req.protocol}://${req.get('host')}`; + const stateObj: SsoState = JSON.parse(decodeURIComponent(state)); + + /** + * Get key from Keycloak + * + * @param header JWT header + * @param callback the callback function + */ + const getKey = (header: JwtHeader, callback: SigningKeyCallback): void => { + jwksClient.getSigningKey(header.kid, (err, key) => { + if (err) { + return callback(err); + } + + if (!key) { + return callback(new Error('Key is undefined')); + } + + const signingKey = key.getPublicKey(); + callback(null, signingKey); + }); + }; + + /** + * Verify the given JWT token + * + * @param idToken the jwt token to verify + */ + const verifyIdToken = async (idToken: string): Promise => { + return new Promise((resolve, reject) => { + verify( + idToken, + getKey, + { + algorithms: ['RS256'], + issuer: KEYCLOAK_ISSUER, + audience: KEYCLOAK_CLIENT_ID, + }, + (err, decoded) => { + if (err) { + return reject(new Error(`Token verification failed: ${err.message}`)); + } + resolve(decoded as JwtFullPayload); + }, + ); + }); + }; + + const tokenUrl = `${KEYCLOAK_ISSUER}/protocol/openid-connect/token`; + + let tokenData: OidcTokenResponse; + let jwtVerifiedPayload: JwtFullPayload; + + try { + const tokenResponse = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: `${thisHost}/sso-callback`, + client_id: KEYCLOAK_CLIENT_ID, + }), + }); + + tokenData = await tokenResponse.json(); + jwtVerifiedPayload = await verifyIdToken(tokenData.id_token); + + adapter.log.debug(JSON.stringify(jwtVerifiedPayload)); + } catch (e) { + adapter.log.error(`Error getting token: ${(e as Error).message}`); + return res.redirect(stateObj.redirectUrl); + } + + if (stateObj.method === 'login') { + const objView = await adapter.getObjectViewAsync('system', 'user', { + startkey: 'system.user.', + endkey: 'system.user.\u9999', + }); + + const item = objView.rows.find( + // @ts-expect-error needs to be allowed explicitly + item => item.value.common?.externalAuthentication?.oidc?.sub === jwtVerifiedPayload.sub, + ); + + if (!item) { + // no user connected to the SSO + return res.redirect(stateObj.redirectUrl); + } + + const username = item.id; + + try { + const params = new URLSearchParams({ + grant_type: 'password', + username, + password: SSO_PASSWORD, + client_id: 'ioBroker', + }); + + const request = new OAuthRequest({ + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'content-length': Buffer.byteLength(params.toString()).toString(), + }, + body: Object.fromEntries(params.entries()), + query: {}, + }); + + const response = new OAuthResponse(res); + const oauthToken = await oauth.token(request, response); + const responseToken = oauthTokenToResponse(oauthToken); + + const redirectUrl = new URL(stateObj.redirectUrl); + redirectUrl.search = new URLSearchParams({ + ssoLoginResponse: JSON.stringify(responseToken), + }).toString(); + + return void res.cookie('access_token', responseToken.access_token).redirect(redirectUrl.toString()); + } catch (e) { + adapter.log.error(`Could not get oauth token: ${(e as Error).message}`); + } + + return res.redirect(stateObj.redirectUrl); + } + + // user connection flow + const userObj = await adapter.getForeignObjectAsync(`system.user.${stateObj.user}`); + + if (!userObj) { + adapter.log.error(`SSO: No existing user object for user "${stateObj.user}"`); + return res.redirect(stateObj.redirectUrl); + } + + // @ts-expect-error needs to be allowed explicitly + userObj.common.externalAuthentication ??= {}; + // @ts-expect-error needs to be allowed explicitly + userObj.common.externalAuthentication.oidc = { sub: jwtVerifiedPayload.sub }; + await adapter.extendForeignObjectAsync(`system.user.${stateObj.user}`, userObj); + + const redirectUrl = new URL(stateObj.redirectUrl); + redirectUrl.search = `id_token=${tokenData.id_token}`; + res.redirect(redirectUrl.toString()); + }); + // Post token. options.app.post('/oauth/token', (req: Request, res: Response) => { const request = new OAuthRequest(req); + + if (request.body.password === SSO_PASSWORD) { + const error = new Error('SSO password used on standard login'); + adapter.log.error(error.message); + return res.status(500).json(error); + } + const response = new OAuthResponse(res); oauth .token(request, response) @@ -84,13 +326,7 @@ export function createOAuth2Server( // Store the access token in a cookie named "access_token" res.cookie('access_token', token.accessToken, cookieOptions); - res.json({ - access_token: token.accessToken, - token_type: 'Bearer', - expires_in: Math.floor((token.accessTokenExpiresAt!.getTime() - Date.now()) / 1000), - refresh_token: token.refreshToken, - refresh_token_expires_in: Math.floor((token.refreshTokenExpiresAt!.getTime() - Date.now()) / 1000), - }); + res.json(oauthTokenToResponse(token)); }) .catch((err: any): void => { res.status(err.code || 500).json(err); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..509fbdb --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,27 @@ +import type OAuth2Server from 'oauth2-server'; + +interface IobrokerOauthResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + refresh_token_expires_in: number; +} + +/** Password to detect SSO, it is too short for official passwords and will be declined on non-SSO requests */ +export const SSO_PASSWORD = 'SSO'; + +/** + * Convert oauth2 token to JSON response + * + * @param token the created OAuth token + */ +export function oauthTokenToResponse(token: OAuth2Server.Token): IobrokerOauthResponse { + return { + access_token: token.accessToken, + token_type: 'Bearer', + expires_in: Math.floor((token.accessTokenExpiresAt!.getTime() - Date.now()) / 1000), + refresh_token: token.refreshToken, + refresh_token_expires_in: Math.floor((token.refreshTokenExpiresAt!.getTime() - Date.now()) / 1000), + }; +} From 10116b9b28c3bc093f83da3a449f5070f2633482 Mon Sep 17 00:00:00 2001 From: foxriver76 Date: Wed, 14 May 2025 13:36:30 +0200 Subject: [PATCH 2/2] some cleanup --- src/lib/oauth2.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/oauth2.ts b/src/lib/oauth2.ts index dbd47aa..2645315 100644 --- a/src/lib/oauth2.ts +++ b/src/lib/oauth2.ts @@ -59,17 +59,22 @@ interface SsoCallbackQuery { state: string; } +/** Response from Keycloak */ interface OidcTokenResponse { access_token: string; refresh_token: string; token_type: 'Bearer'; - /** Used to retrieve the JwtFullPayload */ + /** Used to retrieve the {@link JwtFullPayload} */ id_token: string; 'not-before-policy': number; session_state: string; scope: string; } +/** + * Retrieved by decoding the JWT {@link OidcTokenResponse.id_token} + * The `sub` attribute is used to identify a user in `common.externalAuthentication.oidc.sub` + */ interface JwtFullPayload extends Required { auth_time: number; typ: string;