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"