mirror of https://github.com/louislam/uptime-kuma
				
				
				
			feat: Implement oauth2 monitors (#3119)
* [empty commit] pull request for implement oauth2 monitor * feat: implement oauth2 client credentials flow * fix: auth methods clarification & error handling * docs: fix JSdocs types and clarificationspull/3513/head
							parent
							
								
									cda77c1a32
								
							
						
					
					
						commit
						42b5d30a33
					
				| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<oidc.TokenSet>} 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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -665,6 +665,9 @@
 | 
			
		|||
                                        <option value="basic">
 | 
			
		||||
                                            {{ $t("HTTP Basic Auth") }}
 | 
			
		||||
                                        </option>
 | 
			
		||||
                                        <option value="oauth2-cc">
 | 
			
		||||
                                            {{ $t("OAuth2: Client Credentials") }}
 | 
			
		||||
                                        </option>
 | 
			
		||||
                                        <option value="ntlm">
 | 
			
		||||
                                            NTLM
 | 
			
		||||
                                        </option>
 | 
			
		||||
| 
						 | 
				
			
			@ -688,6 +691,37 @@
 | 
			
		|||
                                            <textarea id="tls-ca" v-model="monitor.tlsCa" class="form-control" :placeholder="$t('Server CA')"></textarea>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    </template>
 | 
			
		||||
                                    <template v-else-if="monitor.authMethod === 'oauth2-cc' ">
 | 
			
		||||
                                        <div class="my-3">
 | 
			
		||||
                                            <label for="oauth_auth_method" class="form-label">{{ $t("Authentication Method") }}</label>
 | 
			
		||||
                                            <select id="oauth_auth_method" v-model="monitor.oauth_auth_method" class="form-select">
 | 
			
		||||
                                                <option value="client_secret_basic">
 | 
			
		||||
                                                    {{ $t("Authorization Header") }}
 | 
			
		||||
                                                </option>
 | 
			
		||||
                                                <option value="client_secret_post">
 | 
			
		||||
                                                    {{ $t("Form Data Body") }}
 | 
			
		||||
                                                </option>
 | 
			
		||||
                                            </select>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                        <div class="my-3">
 | 
			
		||||
                                            <label for="oauth_token_url" class="form-label">{{ $t("OAuth Token URL") }}</label>
 | 
			
		||||
                                            <input id="oauth_token_url" v-model="monitor.oauth_token_url" type="text" class="form-control" :placeholder="$t('OAuth Token URL')" required>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                        <div class="my-3">
 | 
			
		||||
                                            <label for="oauth_client_id" class="form-label">{{ $t("Client ID") }}</label>
 | 
			
		||||
                                            <input id="oauth_client_id" v-model="monitor.oauth_client_id" type="text" class="form-control" :placeholder="$t('Client ID')" required>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                        <template v-if="monitor.oauth_auth_method === 'client_secret_post' || monitor.oauth_auth_method === 'client_secret_basic'">
 | 
			
		||||
                                            <div class="my-3">
 | 
			
		||||
                                                <label for="oauth_client_secret" class="form-label">{{ $t("Client Secret") }}</label>
 | 
			
		||||
                                                <input id="oauth_client_secret" v-model="monitor.oauth_client_secret" type="password" class="form-control" :placeholder="$t('Client Secret')" required>
 | 
			
		||||
                                            </div>
 | 
			
		||||
                                            <div class="my-3">
 | 
			
		||||
                                                <label for="oauth_scopes" class="form-label">{{ $t("OAuth Scope") }}</label>
 | 
			
		||||
                                                <input id="oauth_scopes" v-model="monitor.oauth_scopes" type="text" class="form-control" :placeholder="$t('Optional: Space separated list of scopes')">
 | 
			
		||||
                                            </div>
 | 
			
		||||
                                        </template>
 | 
			
		||||
                                    </template>
 | 
			
		||||
                                    <template v-else>
 | 
			
		||||
                                        <div class="my-3">
 | 
			
		||||
                                            <label for="basicauth-user" class="form-label">{{ $t("Username") }}</label>
 | 
			
		||||
| 
						 | 
				
			
			@ -1123,6 +1157,7 @@ message HealthCheckResponse {
 | 
			
		|||
                    mqttTopic: "",
 | 
			
		||||
                    mqttSuccessMessage: "",
 | 
			
		||||
                    authMethod: null,
 | 
			
		||||
                    oauth_auth_method: "client_secret_basic",
 | 
			
		||||
                    httpBodyEncoding: "json",
 | 
			
		||||
                    kafkaProducerBrokers: [],
 | 
			
		||||
                    kafkaProducerSaslOptions: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue