You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/09/11 19:39:28 UTC

(airavata-custos) 01/03: Redesign the device login prompt and make the logo art configurable

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch pam-logo
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 708e4f953c472e8e31694ecb5e550183654462a3
Author: lahiruj <la...@gmail.com>
AuthorDate: Fri Sep 11 14:32:57 2026 -0400

    Redesign the device login prompt and make the logo art configurable
---
 extensions/CILogon-SSH-PAM/README.md               |  4 +
 extensions/CILogon-SSH-PAM/config_template.json    |  4 +
 extensions/CILogon-SSH-PAM/src/include/config.cpp  |  2 +
 extensions/CILogon-SSH-PAM/src/include/config.hpp  |  4 +-
 .../CILogon-SSH-PAM/src/pam_oauth2_device.cpp      | 90 +++++++++++++++++-----
 .../CILogon-SSH-PAM/src/pam_oauth2_device.hpp      |  3 +-
 extensions/CILogon-SSH-PAM/test/unit.cpp           | 49 +++++++++++-
 7 files changed, 133 insertions(+), 23 deletions(-)

diff --git a/extensions/CILogon-SSH-PAM/README.md b/extensions/CILogon-SSH-PAM/README.md
index 41828cfac..5416e947b 100644
--- a/extensions/CILogon-SSH-PAM/README.md
+++ b/extensions/CILogon-SSH-PAM/README.md
@@ -152,6 +152,9 @@ Thus, at the top level, there is a single object with a number of entries, descr
 | tls | ca\_path | String | N | Directory with trust anchors | Note 2 |
 | qr | | Object | N | | |
 | qr | error\_correction\_level | Int | Y | QR code | Note 3 |
+| prompt | | Object | N | | |
+| prompt | hostname | String | N | System name shown in the sign-in prompt | Note 5 |
+| prompt | logo\_file | String | N | Text file replacing the built-in logo art | Note 5 |
 
 Notes:
 
@@ -162,6 +165,7 @@ Notes:
    * If both ca\_path and ca\_bundle are present, the latter takes precedence
 3 The QR code section is optional but if present, it must have the error correction level defined.  Permitted values are 1 (low), 2 (medium), 3 (high) or -1 (disabled).  If the section is missing, the QR code is disabled.
 4 The "${url}" above would be the URL (hostname) of your OpenID Provider.  Its host certificate must be valid when checked against the CA bundle (see item 2)
+5 The prompt section controls the sign-in message shown over SSH. "hostname" is the system name presented to the user; if unset, "custos-cluster" is shown.  "logo\_file" names a plain-text file whose content is shown in place of the built-in logo art at the top of the prompt, so a site can show its own branding without rebuilding the module; if unset or unreadable, the built-in art is shown. The config is read on every login, so changing the file takes effect immediately. The prompt (log [...]
 
 #### Table 2: Configuring Authorisation Flow
 
diff --git a/extensions/CILogon-SSH-PAM/config_template.json b/extensions/CILogon-SSH-PAM/config_template.json
index a3bad1ef5..56dd6b221 100644
--- a/extensions/CILogon-SSH-PAM/config_template.json
+++ b/extensions/CILogon-SSH-PAM/config_template.json
@@ -25,6 +25,10 @@
     "qr": {
         "error_correction_level": 0
     },
+    "prompt": {
+        "hostname": "cluster.example.org",
+        "logo_file": "/etc/pam_oauth2_device/logo.txt"
+    },
     "group": {
         "access": true,
         "service_name": "stfc-cloud-prod"
diff --git a/extensions/CILogon-SSH-PAM/src/include/config.cpp b/extensions/CILogon-SSH-PAM/src/include/config.cpp
index 3ca87f677..6348a4d53 100644
--- a/extensions/CILogon-SSH-PAM/src/include/config.cpp
+++ b/extensions/CILogon-SSH-PAM/src/include/config.cpp
@@ -149,6 +149,8 @@ Config::load(const char *path)
 	 variable({"oauth","username_attribute"},value(""),true, username_attribute),
 	 variable({"oauth","local_username_suffix"},value(""),false, local_username_suffix),
 	 variable({"qr","error_correction_level"},value(-1),false, qr_error_correction_level),
+	 variable({"prompt","hostname"},value(""),false, prompt_hostname),
+	 variable({"prompt","logo_file"},value(""),false, prompt_logo_file),
 	 variable({"client_debug"},value(false),false, client_debug),
 	 variable({"http_basic_auth"},value(true),false, http_basic_auth),
 	 variable({"cloud","access"},value(false),false, cloud_access),
diff --git a/extensions/CILogon-SSH-PAM/src/include/config.hpp b/extensions/CILogon-SSH-PAM/src/include/config.hpp
index 5a591d30a..f9960ad6e 100644
--- a/extensions/CILogon-SSH-PAM/src/include/config.hpp
+++ b/extensions/CILogon-SSH-PAM/src/include/config.hpp
@@ -30,7 +30,9 @@ public:
         cloud_endpoint,
         cloud_username,
         local_username_suffix,
-        metadata_file;
+        metadata_file,
+        prompt_hostname,
+        prompt_logo_file;
     int qr_error_correction_level;
     bool group_access,
          cloud_access,
diff --git a/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.cpp b/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.cpp
index 4d8913bf4..959395d37 100644
--- a/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.cpp
+++ b/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.cpp
@@ -129,29 +129,75 @@ std::string getQr(const char *text, const int ecc = 0, const int border = 1)
     return oss.str();
 }
 
-std::string DeviceAuthResponse::get_prompt(const int qr_ecc = 0)
+static const char lockup[] =
+    "       ▄▄███████\n"
+    "     ▄██████████\n"
+    "   ▄████████████\n"
+    "    ▀▀█████▀▀▀▀█\n"
+    " ▄▄██▄▄▀▀          ▄█▀▀▀█▄ ██   ██ ▄█▀▀▀█▄ ███████ ▄█▀▀▀█▄ ▄█▀▀▀█▄\n"
+    "████████           ██      ██   ██ ▀█▄▄▄▄    ███   ██   ██ ▀█▄▄▄▄\n"
+    "████████           ██   ▄▄ ██   ██ ▄▄   ██   ███   ██   ██ ▄▄   ██\n"
+    " ▀▀██▀▀▄▄           ▀▀▀▀▀   ▀▀▀▀▀   ▀▀▀▀▀    ▀▀▀    ▀▀▀▀▀   ▀▀▀▀▀\n"
+    "    ▄▄█████▄▄▄▄█\n"
+    "   ▀████████████\n"
+    "     ▀██████████\n"
+    "       ▀▀███████\n";
+
+// Printable characters only in the prompt. The SSH client escapes control
+// bytes in auth messages, so ANSI color codes come out as literal text.
+
+static std::string load_logo(Config const &config)
+{
+    if (!config.prompt_logo_file.empty())
+    {
+        std::ifstream file(config.prompt_logo_file);
+        std::ostringstream art;
+        art << file.rdbuf();
+        if (file && !art.str().empty())
+        {
+            std::string logo = art.str();
+            if (logo.back() != '\n')
+                logo += '\n';
+            return logo;
+        }
+    }
+    return lockup;
+}
+
+std::string DeviceAuthResponse::get_prompt(Config const &config)
 {
     bool complete_url = !verification_uri_complete.empty();
+    std::string const &link = complete_url ? verification_uri_complete : verification_uri;
+
+    std::string host = config.prompt_hostname.empty() ? "custos-cluster" : config.prompt_hostname;
+
     std::ostringstream prompt;
-    prompt << "Authenticate at\n-----------------\n"
-           << (complete_url ? verification_uri_complete : verification_uri)
-           << "\n-----------------\n";
-    if (!complete_url)
+    // the SSH client prints its own "(user@host)" prefix on the first line;
+    // a leading blank line separates it out from the artwork
+    prompt << "\n";
+    prompt << load_logo(config);
+    prompt << "\n"
+           << "Sign in to " << host << " to finish connecting.\n\n"
+           << "  Open this link in a browser\n"
+           << "      " << link << "\n";
+
+    std::string note = complete_url
+        ? "The page will show " + user_code + "."
+        : "Enter code " + user_code + " when asked.";
+    if (expires_in >= 60)
+        note += "  Expires in " + std::to_string(expires_in / 60) + " min.";
+    prompt << "      " << note << "\n";
+
+    if (config.qr_error_correction_level >= 0)
     {
-        prompt << "With code " << user_code
-               << "\n-----------------\n";
+        prompt << "\n  Or scan this code with your phone\n\n";
+        std::istringstream qr(getQr(link.c_str(), config.qr_error_correction_level));
+        std::string row;
+        while (std::getline(qr, row))
+            prompt << "      " << row << "\n";
     }
 
-    if (qr_ecc >= 0) {
-        prompt << "Or scan the QR code to authenticate with a mobile device"
-               << std::endl
-               << std::endl
-               << getQr((complete_url ? verification_uri_complete : verification_uri).c_str(), qr_ecc)
-               << std::endl
-               << "Hit enter when you have finished authenticating\n";
-    } else {
-        prompt << "Hit enter when you have finished authenticating\n";
-    }
+    prompt << "\nPress Enter once you've signed in, or Ctrl-C to cancel.\n";
     return prompt.str();
 }
 
@@ -182,6 +228,10 @@ void make_authorization_request(const Config &config,
         {
             response->verification_uri_complete = data.at("verification_uri_complete");
         }
+        if (data.find("expires_in") != data.end() && data.at("expires_in").is_number())
+        {
+            response->expires_in = data.at("expires_in");
+        }
     }
     catch (json::exception &e)
     {
@@ -291,7 +341,7 @@ get_userinfo(const Config &config,
 }
 
 void show_prompt(pam_handle_t *pamh,
-                 int qr_error_correction_level,
+                 Config const &config,
                  DeviceAuthResponse *device_auth_response)
 {
     int pam_err;
@@ -305,7 +355,7 @@ void show_prompt(pam_handle_t *pamh,
     pam_err = pam_get_item(pamh, PAM_CONV, (const void **)&conv);
     if (pam_err != PAM_SUCCESS)
         throw PamError("Prompt: failed to get PAM_CONV");
-    prompt = device_auth_response->get_prompt(qr_error_correction_level);
+    prompt = device_auth_response->get_prompt(config);
     msg.msg_style = PAM_PROMPT_ECHO_OFF;
     msg.msg = prompt.c_str();
     msgp = &msg;
@@ -508,7 +558,7 @@ PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, cons
             config.client_id, config.client_secret,
             config.scope, config.device_endpoint,
             &device_auth_response);
-        show_prompt(pamh, config.qr_error_correction_level, &device_auth_response);
+        show_prompt(pamh, config, &device_auth_response);
         poll_for_token(config, logger,
 		       config.client_id, config.client_secret,
                        config.token_endpoint,
diff --git a/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.hpp b/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.hpp
index 706e8c8e9..02367d704 100644
--- a/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.hpp
+++ b/extensions/CILogon-SSH-PAM/src/pam_oauth2_device.hpp
@@ -52,7 +52,8 @@ public:
         verification_uri,
         verification_uri_complete,
         device_code;
-    std::string get_prompt(const int qr_ecc);
+    int expires_in = 0;
+    std::string get_prompt(Config const &config);
 };
 
 void make_authorization_request(Config const &config,
diff --git a/extensions/CILogon-SSH-PAM/test/unit.cpp b/extensions/CILogon-SSH-PAM/test/unit.cpp
index 4afec11f9..10174b49e 100644
--- a/extensions/CILogon-SSH-PAM/test/unit.cpp
+++ b/extensions/CILogon-SSH-PAM/test/unit.cpp
@@ -70,7 +70,7 @@ Userinfo get_userinfo(Config const &config,
 		      std::string const &username_attribute);
 
 void show_prompt(pam_handle_t *pamh,
-		 int qr_error_correction_level,
+		 Config const &config,
 		 DeviceAuthResponse *device_auth_response);
 
 bool is_authorized(Config const &config,
@@ -94,6 +94,53 @@ EXPECT_EQ(cmp_file_string("data/qr2.1.txt", getQr(text, 1, 1)), -1);
 EXPECT_EQ(cmp_file_string("data/qr2.2.txt", getQr(text, 2, 1)), -1);
 }
 
+TEST(PamOAuth2Unit, PromptLayout)
+{
+    Config config;
+    config.qr_error_correction_level = 0;
+    config.prompt_hostname = "testhost";
+
+    DeviceAuthResponse response;
+    response.user_code = "XPN-VTQ-NJP";
+    response.verification_uri = "https://cilogon.org/device/";
+    response.verification_uri_complete = "https://cilogon.org/device/?user_code=XPN-VTQ-NJP";
+    response.expires_in = 900;
+
+    std::string prompt = response.get_prompt(config);
+EXPECT_NE(prompt.find("Sign in to testhost to finish connecting."), std::string::npos);
+EXPECT_NE(prompt.find("  Open this link in a browser\n      https://cilogon.org/device/?user_code=XPN-VTQ-NJP\n"), std::string::npos);
+EXPECT_NE(prompt.find("The page will show XPN-VTQ-NJP.  Expires in 15 min."), std::string::npos);
+EXPECT_NE(prompt.find("  Or scan this code with your phone"), std::string::npos);
+EXPECT_NE(prompt.find("Press Enter once you've signed in, or Ctrl-C to cancel."), std::string::npos);
+// 40 rows total, so on a 24-row terminal only the lockup scrolls off
+EXPECT_EQ(std::count(prompt.begin(), prompt.end(), '\n'), 40);
+// the SSH client shows control bytes literally, so none may be emitted
+EXPECT_EQ(prompt.find('\033'), std::string::npos);
+
+    response.verification_uri_complete.clear();
+EXPECT_NE(response.get_prompt(config).find("Enter code XPN-VTQ-NJP when asked."), std::string::npos);
+}
+
+TEST(PamOAuth2Unit, PromptLogoFile)
+{
+    Config config;
+    config.qr_error_correction_level = -1;
+
+    DeviceAuthResponse response;
+    response.user_code = "XPN-VTQ-NJP";
+    response.verification_uri = "https://cilogon.org/device/";
+
+    TempFile logo("SITE ART LINE 1\nSITE ART LINE 2");
+    config.prompt_logo_file = logo.filename();
+    std::string prompt = response.get_prompt(config);
+EXPECT_NE(prompt.find("SITE ART LINE 1\nSITE ART LINE 2\n"), std::string::npos);
+EXPECT_EQ(prompt.find("████████"), std::string::npos);
+
+    // missing file falls back to the built-in art
+    config.prompt_logo_file = "/nonexistent/logo.txt";
+EXPECT_NE(response.get_prompt(config).find("████████"), std::string::npos);
+}
+
 TEST(PamOAuth2Unit, IsAuthorized)
 {
     // Userinfo contains the remote username. Note the suffix is (or will be) configured as ".test" to match a local username "fred"