diff --git a/db/patch-monitor-oauth-cc.sql b/db/patch-monitor-oauth-cc.sql new file mode 100644 index 000000000..f33e95298 --- /dev/null +++ b/db/patch-monitor-oauth-cc.sql @@ -0,0 +1,19 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD oauth_client_id TEXT default null; + +ALTER TABLE monitor + ADD oauth_client_secret TEXT default null; + +ALTER TABLE monitor + ADD oauth_token_url TEXT default null; + +ALTER TABLE monitor + ADD oauth_scopes TEXT default null; + +ALTER TABLE monitor + ADD oauth_auth_method TEXT default null; + +COMMIT; diff --git a/package-lock.json b/package-lock.json index d83b7c79a..1d557e5a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "nodemailer": "~6.6.5", "nostr-tools": "^1.13.1", "notp": "~2.0.3", + "openid-client": "^5.4.2", "password-hash": "~1.2.2", "pg": "~8.8.0", "pg-connection-string": "~2.5.0", @@ -13001,6 +13002,14 @@ "topo": "3.x.x" } }, + "node_modules/jose": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.3.tgz", + "integrity": "sha512-YPM9Q+dmsna4CGWNn5+oHFsuXJdxvKAOVoNjpe2nje3odSoX5Xz4s71rP50vM8uUKJyQtMnEGPmbVCVR+G4W5g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-md4": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", @@ -14665,6 +14674,14 @@ "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==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -14698,6 +14715,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -14756,6 +14781,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.2.tgz", + "integrity": "sha512-lIhsdPvJ2RneBm3nGBBhQchpe3Uka//xf7WPHTIglery8gnckvW7Bd9IaQzekzXJvWthCMyi/xVEyGW0RFPytw==", + "dependencies": { + "jose": "^4.14.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/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/openid-client/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/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", diff --git a/package.json b/package.json index f88c63e4f..9f57e99b7 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "nodemailer": "~6.6.5", "nostr-tools": "^1.13.1", "notp": "~2.0.3", + "openid-client": "^5.4.2", "password-hash": "~1.2.2", "pg": "~8.8.0", "pg-connection-string": "~2.5.0", diff --git a/server/database.js b/server/database.js index 7b1d9f932..7deea83d2 100644 --- a/server/database.js +++ b/server/database.js @@ -75,6 +75,7 @@ class Database { "patch-added-json-query.sql": true, "patch-added-kafka-producer.sql": true, "patch-add-certificate-expiry-status-page.sql": true, + "patch-monitor-oauth-cc.sql": true, }; /** diff --git a/server/model/monitor.js b/server/model/monitor.js index ecdece997..8f32132ba 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -6,7 +6,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVA SQL_DATETIME_FORMAT } = require("../../src/util"); const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery, - redisPingAsync, mongodbPing, kafkaProducerAsync + redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, } = require("../util-server"); const { R } = require("redbean-node"); const { BeanModel } = require("redbean-node/dist/bean-model"); @@ -154,6 +154,11 @@ class Monitor extends BeanModel { grpcMetadata: this.grpcMetadata, basic_auth_user: this.basic_auth_user, basic_auth_pass: this.basic_auth_pass, + oauth_client_id: this.oauth_client_id, + oauth_client_secret: this.oauth_client_secret, + oauth_token_url: this.oauth_token_url, + oauth_scopes: this.oauth_scopes, + oauth_auth_method: this.oauth_auth_method, pushToken: this.pushToken, databaseConnectionString: this.databaseConnectionString, radiusUsername: this.radiusUsername, @@ -374,6 +379,24 @@ class Monitor extends BeanModel { }; } + // OIDC: Basic client credential flow. + // Additional grants might be implemented in the future + let oauth2AuthHeader = {}; + if (this.auth_method === "oauth2-cc") { + try { + if (this.oauthAccessToken === undefined || new Date(this.oauthAccessToken.expires_at * 1000) <= new Date()) { + log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new one`); + this.oauthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method); + log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken.expires_at * 1000)}`); + } + oauth2AuthHeader = { + "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token, + }; + } catch (e) { + throw new Error("The oauth config is invalid. " + e.message); + } + } + const httpsAgentOptions = { maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) rejectUnauthorized: !this.getIgnoreTls(), @@ -408,6 +431,7 @@ class Monitor extends BeanModel { "User-Agent": "Uptime-Kuma/" + version, ...(contentType ? { "Content-Type": contentType } : {}), ...(basicAuthHeader), + ...(oauth2AuthHeader), ...(this.headers ? JSON.parse(this.headers) : {}) }, maxRedirects: this.maxredirects, diff --git a/server/server.js b/server/server.js index 9eabaebab..4917402d8 100644 --- a/server/server.js +++ b/server/server.js @@ -713,6 +713,11 @@ let needSetup = false; bean.headers = monitor.headers; bean.basic_auth_user = monitor.basic_auth_user; bean.basic_auth_pass = monitor.basic_auth_pass; + bean.oauth_client_id = monitor.oauth_client_id, + bean.oauth_client_secret = monitor.oauth_client_secret, + bean.oauth_auth_method = this.oauth_auth_method, + bean.oauth_token_url = monitor.oauth_token_url, + bean.oauth_scopes = monitor.oauth_scopes, bean.tlsCa = monitor.tlsCa; bean.tlsCert = monitor.tlsCert; bean.tlsKey = monitor.tlsKey; diff --git a/server/util-server.js b/server/util-server.js index ece0456de..8ab592ebc 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -21,6 +21,8 @@ const grpc = require("@grpc/grpc-js"); const protojs = require("protobufjs"); const radiusClient = require("node-radius-client"); const redis = require("redis"); +const oidc = require("openid-client"); + const { dictionaries: { rfc2865: { file, attributes }, @@ -52,6 +54,43 @@ exports.initJWTSecret = async () => { return jwtSecretBean; }; +/** + * Decodes a jwt and returns the payload portion without verifying the jqt. + * @param {string} jwt The input jwt as a string + * @returns {Object} Decoded jwt payload object + */ +exports.decodeJwt = (jwt) => { + return JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString()); +}; + +/** + * Gets a Access Token form a oidc/oauth2 provider + * @param {string} tokenEndpoint The token URI form the auth service provider + * @param {string} clientId The oidc/oauth application client id + * @param {string} clientSecret The oidc/oauth application client secret + * @param {string} scope The scope the for which the token should be issued for + * @param {string} authMethod The method on how to sent the credentials. Default client_secret_basic + * @returns {Promise} TokenSet promise if the token request was successful + */ +exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSecret, scope, authMethod = "client_secret_basic") => { + const oauthProvider = new oidc.Issuer({ token_endpoint: tokenEndpoint }); + let client = new oauthProvider.Client({ + client_id: clientId, + client_secret: clientSecret, + token_endpoint_auth_method: authMethod + }); + + // Increase default timeout and clock tolerance + client[oidc.custom.http_options] = () => ({ timeout: 10000 }); + client[oidc.custom.clock_tolerance] = 5; + + let grantParams = { grant_type: "client_credentials" }; + if (scope) { + grantParams.scope = scope; + } + return await client.grant(grantParams); +}; + /** * Send TCP request to specified hostname and port * @param {string} hostname Hostname / address of machine diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 93db2c260..333dfaaff 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -665,6 +665,9 @@ + @@ -688,6 +691,37 @@ +