From fa3fec7d77a7e57ae7b40dd68e110f35891190bd Mon Sep 17 00:00:00 2001 From: Falk Nisius Date: Wed, 16 Apr 2025 07:01:51 +0200 Subject: [PATCH 1/7] feat: add .idea to ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 302f652..3836a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +.idea /build /packaging-build From 647f17410418dbe8c74ec983b31c78944f5a6b41 Mon Sep 17 00:00:00 2001 From: Falk Nisius Date: Wed, 16 Apr 2025 15:59:01 +0200 Subject: [PATCH 2/7] feat: debug build dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3836a3a..832150f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea +/cmake-build-debug /build /packaging-build From 54d6b1c7945d44fc611af41f119ea5152273efd5 Mon Sep 17 00:00:00 2001 From: Falk Nisius Date: Wed, 16 Apr 2025 16:00:41 +0200 Subject: [PATCH 3/7] feat: allow additional option manage_token_externally to prevent refresh and prevent assume refresh_token in token file feat: add additional option use_client_credentials to use the client_credentials grant_type on refresh --- src/config.cc | 8 +++++++ src/config.h | 4 ++++ src/token_store.cc | 57 +++++++++++++++++++++++++++++++++++++++------- src/token_store.h | 4 +++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/config.cc b/src/config.cc index b3a93f6..e17868f 100644 --- a/src/config.cc +++ b/src/config.cc @@ -178,6 +178,14 @@ int Config::Init(const Json::Value &root) { err = Fetch(root, "ca_certs_dir", true, &ca_certs_dir_); if (err != SASL_OK) return err; + err = Fetch(root, "manage_token_externally", true, + &manage_token_externally_); + if (err != SASL_OK) return err; + + err = Fetch(root, "use_client_credentials", true, + &use_client_credentials_); + if (err != SASL_OK) return err; + return 0; } catch (const std::exception &e) { diff --git a/src/config.h b/src/config.h index c52d311..0722e4f 100644 --- a/src/config.h +++ b/src/config.h @@ -37,6 +37,8 @@ class Config { bool always_log_to_syslog() const { return always_log_to_syslog_; } bool log_to_syslog_on_failure() const { return log_to_syslog_on_failure_; } bool log_full_trace_on_failure() const { return log_full_trace_on_failure_; } + bool manage_token_externally() const { return manage_token_externally_; } + bool use_client_credentials() const { return use_client_credentials_; } std::string token_endpoint() const { return token_endpoint_; } std::string proxy() const { return proxy_; } std::string ca_bundle_file() const { return ca_bundle_file_; } @@ -53,6 +55,8 @@ class Config { bool always_log_to_syslog_ = false; bool log_to_syslog_on_failure_ = true; bool log_full_trace_on_failure_ = false; + bool manage_token_externally_ = false; + bool use_client_credentials_ = false; std::string token_endpoint_ = "https://accounts.google.com/o/oauth2/token"; std::string proxy_ = ""; std::string ca_bundle_file_ = ""; diff --git a/src/token_store.cc b/src/token_store.cc index c778d50..4135d7c 100644 --- a/src/token_store.cc +++ b/src/token_store.cc @@ -48,6 +48,14 @@ std::string GetTempSuffix() { return std::string(buf); } +bool s_to_b(const std::string in) { + std::for_each(in.begin(), in.end(), [](char c) { return std::tolower(c); }); + if (in == "yes" || in == "true") { + return true; + } + return false; +} + void ReadOverride(const Json::Value &root, const std::string &key, std::optional *output) { if (root.isMember(key)) { @@ -73,6 +81,14 @@ void WriteOverride(const std::string &key, } int TokenStore::GetAccessToken(std::string *token) { + const bool manage_token_externally = manage_token_externally_.value_or( + Config::Get()->manage_token_externally()); + + if ( manage_token_externally ) { + *token = access_; + return SASL_OK; + } + const int refresh_window = override_refresh_window_.value_or(Config::Get()->refresh_window()); @@ -109,10 +125,15 @@ int TokenStore::Refresh() { const std::string ca_certs_dir = override_ca_certs_dir_.value_or(Config::Get()->ca_certs_dir()); + const bool use_client_credentials = use_client_credentials_.value_or(Config::Get()->use_client_credentials()); + const std::string request = std::string("client_id=") + client_id + "&client_secret=" + client_secret + - "&grant_type=refresh_token&refresh_token=" + refresh_; + (use_client_credentials + ? std::string("&grant_type=client_credentials") + : ("&grant_type=refresh_token&refresh_token=" + refresh_.value_or(""))); + std::string response; long response_code = 0; log_->Write("TokenStore::Refresh: token_endpoint: %s", @@ -188,9 +209,24 @@ int TokenStore::Read() { Json::Value root; file >> root; - if (!root.isMember("refresh_token")) { - log_->Write("TokenStore::Read: missing refresh_token"); - return SASL_FAIL; + + if (root.isMember("manage_token_externally")) + manage_token_externally_ = + s_to_b(root["manage_token_externally"].asString()); + if (root.isMember("use_client_credentials")) + use_client_credentials_ = s_to_b(root["use_client_credentials"].asString()); + + const bool manage_token_externally = manage_token_externally_.value_or( + Config::Get()->manage_token_externally()); + const bool use_client_credentials = use_client_credentials_.value_or( + Config::Get()->use_client_credentials()); + + if (!(manage_token_externally || use_client_credentials)) { + if (!root.isMember("refresh_token")) { + log_->Write("TokenStore::Read: missing refresh_token"); + return SASL_FAIL; + } + refresh_ = root["refresh_token"].asString(); } ReadOverride(root, "client_id", &override_client_id_); @@ -203,15 +239,14 @@ int TokenStore::Read() { if (root.isMember("refresh_window")) override_refresh_window_ = stoi(root["refresh_window"].asString()); - refresh_ = root["refresh_token"].asString(); if (root.isMember("access_token")) access_ = root["access_token"].asString(); if (root.isMember("expiry")) expiry_ = stoi(root["expiry"].asString()); ReadOverride(root, "user", &user_); - log_->Write("TokenStore::Read: refresh=%s, access=%s, user=%s", - refresh_.c_str(), access_.c_str(), user_.value_or("").c_str()); + log_->Write("TokenStore::Read: access=%s, user=%s", access_.c_str(), + user_.value_or("").c_str()); return SASL_OK; } catch (const std::exception &e) { @@ -230,10 +265,10 @@ int TokenStore::Write() { try { Json::Value root; - root["refresh_token"] = refresh_; root["access_token"] = access_; root["expiry"] = std::to_string(expiry_); + WriteOverride("refresh_token", refresh_, &root) ; WriteOverride("user", user_, &root); WriteOverride("client_id", override_client_id_, &root); @@ -246,6 +281,12 @@ int TokenStore::Write() { if (override_refresh_window_) { root["refresh_window"] = std::to_string(*override_refresh_window_); } + if (use_client_credentials_) { + root["use_client_credentials"] = std::to_string(*use_client_credentials_); + } + if (manage_token_externally_) { + root["manage_token_externally"] = std::to_string(*manage_token_externally_); + } std::ofstream file(new_path); if (!file.good()) { diff --git a/src/token_store.h b/src/token_store.h index c290bba..b1bb9e8 100644 --- a/src/token_store.h +++ b/src/token_store.h @@ -56,9 +56,11 @@ class TokenStore { std::optional override_ca_bundle_file_; std::optional override_ca_certs_dir_; std::optional override_refresh_window_ = 0; + std::optional manage_token_externally_ = false; + std::optional use_client_credentials_ = false; std::string access_; - std::string refresh_; + std::optional refresh_; std::optional user_; time_t expiry_ = 0; From 1467a65203be50503a55dcb4963ec3e81ddd1bb0 Mon Sep 17 00:00:00 2001 From: Falk Nisius Date: Wed, 16 Apr 2025 16:00:50 +0200 Subject: [PATCH 4/7] feat: describe the new options --- docs/sasl-xoauth2.conf.5.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/sasl-xoauth2.conf.5.md b/docs/sasl-xoauth2.conf.5.md index a398f05..b1dad26 100644 --- a/docs/sasl-xoauth2.conf.5.md +++ b/docs/sasl-xoauth2.conf.5.md @@ -66,6 +66,14 @@ The top-level JSON object can contain the following keys: : if set, overrides the default 10 second refresh window with the specified time in seconds (integer) +`manage_token_externally` + +: if set, especially in the token file, the plugin takes the token as is + +`use_client_credentials` + +: if set, the client credentials grant flow is used, instead of the refresh_token flow + # TOKEN FILE In addition to this file, `sasl-xoauth2` relies on a "token file" which it updates independently. From 36dc46bf8a79f7f573f7118caaf6760635c3b000 Mon Sep 17 00:00:00 2001 From: Falk Nisius Date: Wed, 16 Apr 2025 18:04:25 +0200 Subject: [PATCH 5/7] feat: add scope to config and documentation --- README.md | 21 +++++++++++++++++++++ docs/sasl-xoauth2.conf.5.md | 6 +++++- src/config.cc | 3 +++ src/config.h | 2 ++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 04a81f4..7b22e51 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ sasl-xoauth2 also provides two configuration variables, `ca_bundle_file` and libraries will look for a CA certificate bundle (for `ca_bundle_file`) or a set of CA certificates (for `ca_certs_dir`). Specify one or the other, but not both. +**Remember curl expect a new line after the last certificate in the bundle. If it is missed curl aborts with an read error.** + #### A Note on postmulti [@jamenlang](https://github.com/jamenlang) has provided a [very helpful @@ -403,6 +405,25 @@ or: $ sudo chown -R postfix:postfix /var/spool/postfix/etc/tokens ``` +### Outlook/Office 365 Configuration (Client Credentials Flow) + +The initial work at azure entra platform for creating an app is the same like the device flow. +For preventing initial interaction with a browser we add a secret to the app and can use it for the client credential flow. + +Store the client ID and secret in `/etc/sasl-xoauth2.conf`: + +```json +{ + "client_id": "client ID goes here", + "client_secret": "client secret goes here", + "use_client_credentials": "true", + "scope": "https://outlook.office365.com/.default", + "token_endpoint": "https://login.microsoftonline.com//oauth2/v2.0/token" +} +``` + +This flow prevents the need of a refresh token in the token answer. + ### Outlook/Office 365 Configuration (Legacy Client) (Deprecated) #### Client Credentials diff --git a/docs/sasl-xoauth2.conf.5.md b/docs/sasl-xoauth2.conf.5.md index b1dad26..c701938 100644 --- a/docs/sasl-xoauth2.conf.5.md +++ b/docs/sasl-xoauth2.conf.5.md @@ -34,6 +34,10 @@ The top-level JSON object can contain the following keys: : authenticates this client for OAuth 2 token requests; world-readable by default (but see below to place this in token files instead) +`scope` + +: if the client credentials grant is used, we need a scope. Specially for office365.com where the default scope should be: `https://outlook.office365.com/.default` + `always_log_to_syslog` : always write plugin log messages to syslog, even for successful runs; may contain tokens/secrets (defaults to "no") @@ -56,7 +60,7 @@ The top-level JSON object can contain the following keys: `ca_bundle_file` -: if set, overrides CURL's default certificate-authority bundle file +: if set, overrides CURL's default certificate-authority bundle file. **Remember curl expect a new line after the last certificate in the bundle**. `ca_certs_dir` diff --git a/src/config.cc b/src/config.cc index e17868f..4e8cceb 100644 --- a/src/config.cc +++ b/src/config.cc @@ -151,6 +151,9 @@ int Config::Init(const Json::Value &root) { err = Fetch(root, "client_secret", false, &client_secret_); if (err != SASL_OK) return err; + err = Fetch(root, "scope", true, &scope_); + if (err != SASL_OK) return err; + err = Fetch(root, "always_log_to_syslog", true, &always_log_to_syslog_); if (err != SASL_OK) return err; diff --git a/src/config.h b/src/config.h index 0722e4f..b995b94 100644 --- a/src/config.h +++ b/src/config.h @@ -34,6 +34,7 @@ class Config { std::string client_id() const { return client_id_; } std::string client_secret() const { return client_secret_; } + std::string scope() const { return scope_; } bool always_log_to_syslog() const { return always_log_to_syslog_; } bool log_to_syslog_on_failure() const { return log_to_syslog_on_failure_; } bool log_full_trace_on_failure() const { return log_full_trace_on_failure_; } @@ -52,6 +53,7 @@ class Config { std::string client_id_; std::string client_secret_; + std::string scope_; bool always_log_to_syslog_ = false; bool log_to_syslog_on_failure_ = true; bool log_full_trace_on_failure_ = false; From f9f69d05f494559ae02ef7f8439910d8f87cb611 Mon Sep 17 00:00:00 2001 From: Falk Nisius Date: Wed, 16 Apr 2025 18:05:03 +0200 Subject: [PATCH 6/7] feat: add scope to ovverride config, use in the client credential flow, and serialize optional flags in a better way --- src/token_store.cc | 8 +++++--- src/token_store.h | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/token_store.cc b/src/token_store.cc index 4135d7c..717f6dc 100644 --- a/src/token_store.cc +++ b/src/token_store.cc @@ -131,7 +131,7 @@ int TokenStore::Refresh() { std::string("client_id=") + client_id + "&client_secret=" + client_secret + (use_client_credentials - ? std::string("&grant_type=client_credentials") + ? (std::string("&grant_type=client_credentials&scope=") + override_scope_.value_or(Config::Get()->scope())) : ("&grant_type=refresh_token&refresh_token=" + refresh_.value_or(""))); std::string response; @@ -235,6 +235,7 @@ int TokenStore::Read() { ReadOverride(root, "proxy", &override_proxy_); ReadOverride(root, "ca_bundle_file", &override_ca_bundle_file_); ReadOverride(root, "ca_certs_dir", &override_ca_certs_dir_); + ReadOverride(root, "scope", &override_scope_); if (root.isMember("refresh_window")) override_refresh_window_ = stoi(root["refresh_window"].asString()); @@ -270,6 +271,7 @@ int TokenStore::Write() { WriteOverride("refresh_token", refresh_, &root) ; WriteOverride("user", user_, &root); + WriteOverride("scope", override_scope_, &root); WriteOverride("client_id", override_client_id_, &root); WriteOverride("client_secret", override_client_secret_, &root); @@ -282,10 +284,10 @@ int TokenStore::Write() { root["refresh_window"] = std::to_string(*override_refresh_window_); } if (use_client_credentials_) { - root["use_client_credentials"] = std::to_string(*use_client_credentials_); + root["use_client_credentials"] = use_client_credentials_ ? "true" : "false"; } if (manage_token_externally_) { - root["manage_token_externally"] = std::to_string(*manage_token_externally_); + root["manage_token_externally"] = manage_token_externally_ ? "true" : "false"; } std::ofstream file(new_path); diff --git a/src/token_store.h b/src/token_store.h index b1bb9e8..4990762 100644 --- a/src/token_store.h +++ b/src/token_store.h @@ -55,6 +55,7 @@ class TokenStore { std::optional override_proxy_; std::optional override_ca_bundle_file_; std::optional override_ca_certs_dir_; + std::optional override_scope_; std::optional override_refresh_window_ = 0; std::optional manage_token_externally_ = false; std::optional use_client_credentials_ = false; From c1a54d923e6a49d26f16d77523e6e05b7476b660 Mon Sep 17 00:00:00 2001 From: Falk Nisius Date: Tue, 3 Jun 2025 17:22:07 +0200 Subject: [PATCH 7/7] feat: ensure optional boolean values are used right, specially at writing json --- src/token_store.cc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/token_store.cc b/src/token_store.cc index 717f6dc..0f176de 100644 --- a/src/token_store.cc +++ b/src/token_store.cc @@ -283,11 +283,11 @@ int TokenStore::Write() { if (override_refresh_window_) { root["refresh_window"] = std::to_string(*override_refresh_window_); } - if (use_client_credentials_) { - root["use_client_credentials"] = use_client_credentials_ ? "true" : "false"; + if (use_client_credentials_.has_value()) { + root["use_client_credentials"] = use_client_credentials_.value_or(false) ? "true" : "false"; } - if (manage_token_externally_) { - root["manage_token_externally"] = manage_token_externally_ ? "true" : "false"; + if (manage_token_externally_.has_value()) { + root["manage_token_externally"] = manage_token_externally_.value_or(false) ? "true" : "false"; } std::ofstream file(new_path);