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 18:39:39 UTC
(airavata-custos) branch master updated (ae379e49c -> e5f29acc3)
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
from ae379e49c Fix OIDC sub linking for provisioned users and SLURM usage amounts (#567)
new b36378e26 Redesign the device login prompt and make the logo art configurable
new e5f29acc3 Build the PAM module from extensions and deploy the prompt logo art
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
dev-ops/account-provisioning/enroll-node.yml | 17 +++-
.../group_vars/all.yml.example | 17 +++-
.../templates/pam-oauth2-config.json.j2 | 7 +-
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 +++++++++++-
10 files changed, 167 insertions(+), 30 deletions(-)
(airavata-custos) 02/02: Build the PAM module from extensions and deploy the prompt logo art
Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit e5f29acc363dbeac88ab5cf9f5693d224cfb0774
Author: lahiruj <la...@gmail.com>
AuthorDate: Fri Sep 11 14:39:32 2026 -0400
Build the PAM module from extensions and deploy the prompt logo art
---
dev-ops/account-provisioning/enroll-node.yml | 17 ++++++++++++++---
dev-ops/account-provisioning/group_vars/all.yml.example | 17 ++++++++++++++---
.../templates/pam-oauth2-config.json.j2 | 7 ++++++-
3 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/dev-ops/account-provisioning/enroll-node.yml b/dev-ops/account-provisioning/enroll-node.yml
index 5a2075645..d0d83663f 100644
--- a/dev-ops/account-provisioning/enroll-node.yml
+++ b/dev-ops/account-provisioning/enroll-node.yml
@@ -188,21 +188,22 @@
state: present
tags: [pam]
- - name: "[pam] Clone pam_oauth2_device repository"
+ - name: "[pam] Clone pam_oauth2_device source"
git:
repo: "{{ pam_oauth2_device_repo }}"
dest: "{{ pam_oauth2_device_src_dir }}"
+ version: "{{ pam_oauth2_device_version | default('master') }}"
force: true
tags: [pam]
- name: "[pam] Build pam_oauth2_device"
make:
- chdir: "{{ pam_oauth2_device_src_dir }}"
+ chdir: "{{ pam_oauth2_device_build_dir }}"
tags: [pam]
- name: "[pam] Install pam_oauth2_device.so"
copy:
- src: "{{ pam_oauth2_device_src_dir }}/pam_oauth2_device.so"
+ src: "{{ pam_oauth2_device_build_dir }}/pam_oauth2_device.so"
dest: /usr/lib64/security/pam_oauth2_device.so
remote_src: true
owner: root
@@ -219,6 +220,16 @@
mode: "0755"
tags: [pam]
+ - name: "[pam] Copy custom logo art"
+ copy:
+ src: "{{ pam_logo_file_src }}"
+ dest: /etc/pam_oauth2_device/logo.txt
+ owner: root
+ group: root
+ mode: "0644"
+ when: pam_logo_file_src is defined
+ tags: [pam]
+
- name: "[pam] Template pam_oauth2_device config.json"
template:
src: templates/pam-oauth2-config.json.j2
diff --git a/dev-ops/account-provisioning/group_vars/all.yml.example b/dev-ops/account-provisioning/group_vars/all.yml.example
index 9618de85a..3a60e69ab 100644
--- a/dev-ops/account-provisioning/group_vars/all.yml.example
+++ b/dev-ops/account-provisioning/group_vars/all.yml.example
@@ -65,10 +65,14 @@ sssd_domain_name: "xxx"
# -----------------------------------------------------------------------------
# Git repo to clone for building pam_oauth2_device
-pam_oauth2_device_repo: "https://github.com/cyber-shuttle/pam_oauth2_device.git"
+pam_oauth2_device_repo: "https://github.com/apache/airavata-custos.git"
-# Where to clone the source on the target
-pam_oauth2_device_src_dir: "/usr/local/src/pam_oauth2_device"
+# Branch or tag to build from
+pam_oauth2_device_version: "master"
+
+# Where to clone the source on the target, and where the module builds
+pam_oauth2_device_src_dir: "/usr/local/src/pam_oauth2_device/airavata-custos"
+pam_oauth2_device_build_dir: "{{ pam_oauth2_device_src_dir }}/extensions/CILogon-SSH-PAM"
# LDAP settings for pam_oauth2_device user lookup
# This lets the PAM module verify the SSH username exists in LDAP
@@ -82,6 +86,13 @@ pam_oauth2_ldap_attr: "uid"
# Enable debug logging in pam_oauth2_device (set false for production)
pam_oauth2_debug: false
+# System name shown in the SSH sign-in prompt (defaults to the inventory hostname)
+# pam_prompt_hostname: "login.cluster.example.org"
+
+# Local text file shown in place of the built-in logo art in the sign-in prompt.
+# Copied to /etc/pam_oauth2_device/logo.txt on the node; leave unset for the default art.
+# pam_logo_file_src: "~/cluster-logo.txt"
+
# -----------------------------------------------------------------------------
# Validation
# -----------------------------------------------------------------------------
diff --git a/dev-ops/account-provisioning/templates/pam-oauth2-config.json.j2 b/dev-ops/account-provisioning/templates/pam-oauth2-config.json.j2
index 8ff3a0dad..14be7337a 100644
--- a/dev-ops/account-provisioning/templates/pam-oauth2-config.json.j2
+++ b/dev-ops/account-provisioning/templates/pam-oauth2-config.json.j2
@@ -41,6 +41,11 @@
"filter": "{{ pam_oauth2_ldap_filter }}",
"attr": "{{ pam_oauth2_ldap_attr }}"
},
- "qr": { "show": false, "error_correction_level": 1 },
+ "qr": { "error_correction_level": 0 },
+ "prompt": {
+ "hostname": "{{ pam_prompt_hostname | default(inventory_hostname) }}"{% if pam_logo_file_src is defined %},
+ "logo_file": "/etc/pam_oauth2_device/logo.txt"
+{% endif %}
+ },
"tls": { "ca_bundle": "{{ ldap_tls_cacert }}" }
}
(airavata-custos) 01/02: Redesign the device login prompt and make the logo art configurable
Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit b36378e2693da546d07da72e666a430701427db5
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"