Skip to content

Add pam_interactive authentication flow #752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

khansameer25
Copy link

Client-side implementation for the pam_interactive authentication flow. This is a direct port of the C++ Pam Interactive Auth Plugin

Unlike native or pam_password, pam_interactive supports a multi-step flow where the server controls the prompts (e.g., password, OTP, PIN)

The write_pam_interactive_irodsA_file function performs a one-time interactive login using pam_interactive and generates a .irodsA file for authentication in future sessions through the native scheme. It follows the pattern of existing free functions for the other auth flows, reusing their logic but omitting a password parameter, as prompts and inputs are handled interactively by the server.

Performed basic manual testing on an iRODS 5.0.1 server (without SSL/TLS), including successful login with correct credentials and failed login with incorrect credentials using simple single-password setups. More complex multi-step flows remain untested.

@alanking alanking requested a review from d-w-moore July 1, 2025 13:57
Copy link
Contributor

@korydraughn korydraughn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice.

Have you written any tests for this?

Copy link
Contributor

@alanking alanking left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks nice so far.

Yes, some basic automated tests will be needed for this.

Comment on lines 59 to 66
ensure_ssl = request.pop(ENSURE_SSL_IS_ACTIVE, None)
if ensure_ssl is not None:
self.check_ssl = ensure_ssl

if self.check_ssl:
if not isinstance(self.conn.socket, ssl.SSLSocket):
msg = "pam_interactive auth scheme requires secure communications (TLS/SSL) with the server."
raise RuntimeError(msg)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pam_interactive plugin has an "insecure mode" which allows for communications with the server without TLS. The server will enforce the use of TLS in the agent auth request step, so this check is not necessary unless this client will require TLS regardless. This would differ from the behavior of the C++ client plugin.

That being said, I might be missing something with regards to the ENSURE_SSL_IS_ACTIVE configuration. @d-w-moore may know more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, the C++ client plugin does not perform this check. I included it to remain consistent with the pam_password implementation, which has a client-side check. If this is not the preferred approach, I can remove it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, TLS is indeed required for pam_password authentication (at least in iRODS 5+). The client-side check was implemented in PRC because the server-side check is "too late" (if you're curious about what I mean by that, see: irods/irods#8360 (comment)). pam_password also does not have an insecure mode feature (yet: irods/irods#8117).

The ENSURE_SSL_IS_ACTIVE configuration appears to be specific to pam_password, so honoring it here would expand its scope.

I think this is just a decision we will have to make for this client. The options are...

  1. Enforce TLS usage for pam_interactive authentication in PRC.
  2. TLS enforcement is deferred to the connected server.

Perhaps we can discuss this and write back here once we've reached consensus.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussion, we've decided on 2. Let's remove these checks.

Comment on lines 158 to 163
if retr_path and retr_path.startswith('/'):
key = retr_path[1:]
pstate = req.get("pstate", {})
if key in pstate:
req["resp"] = pstate[key]
return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name key is referring to what could be a whole "path" to an item in a sub-object (i.e. it's supposed to be treated as a JSON pointer). Will that work with the pstate dict?

For reference: https://github.com/irods/irods_auth_plugin_pam_interactive/blob/9092c0dfbd1151f8ff53214d6da30ea64b72eac2/plugin/src/main.cpp#L228

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching that. I overlooked the need to handle nested JSON pointers. Would the same apply to _get_default_value and _patch_state since they also process similar paths? If so, would using the jsonpointer and jsonpatch libraries be best?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the same apply to _get_default_value and _patch_state since they also process similar paths?

Yes, I think so.

Hmm... I am not excited about the prospect of bringing in more dependencies, but it may be better to do so than trying to implement this ourselves (at least for the sake of time). Try it out and see if things work as you expect. We can discuss this, as well.

Copy link
Contributor

@korydraughn korydraughn Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some discussion, we've agreed that introducing more dependencies is fine - we just need to review the license for any dependencies to make sure they are okay to bring in. We also need to be cautious of the new dependencies bringing in a large number of secondary dependencies.


import getpass
import sys
import logging
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could alphabetize. Not critical

Copy link
Collaborator

@d-w-moore d-w-moore left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for undertaking this, @khansameer25 . This effort looks really good, and with some tests and extra explanatory pointers/comments (for people unfamiliar as yet to pam_interactive as a strategy) could be perfect.

Comment on lines +61 to +62
resp["pstate"] = resp.get("pstate", {})
resp["pdirty"] = resp.get("pdirty", False)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my sake being yet unfamiliar with but still a potential maintainer of the pam_interactive strategy and client plugin, some of the sections of code could be adorned with explanatory comments esp. where they involve server interactions and the associated key-strings for the exchange, as these seem to be external and not self-explanatory within this body of code. This could mean using web links to other explanations, to save on repetition.

return True

# If no value found in pstate, set resp to empty string
req["resp"] = ""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What, if anything, does the server do when it finds the response is empty? Just trying to understand the overall process here. Again, this could be a possible subject to add to the already existing comment.

if is_password:
user_input = getpass.getpass(display_prompt)
else:
sys.stdout.write(display_prompt)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually stderr is chosen for outputting prompts, rather than stdout


# Pass through and failure states

def next(self, request):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, as a beginner who hasn't worked with the pam_interactive plugin before, I'd be interested in a comment maintaining that part played in the overall function of server/client interaction by such methods. Plus, I'm a little confused at trying to parse the existing comment, "pass through and failure states."


return resp

def running(self, request):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming we arrive at such states as "running" and "waiting[_pw]" as a direct result of the server altering NEXT_OPERATION field. Please correct if wrong, or further adorn with comments to help the user/maintainer.

@@ -115,3 +115,35 @@ def write_pam_credentials_to_secrets_file(password, overwrite=True, ttl="", **kw
raise RuntimeError(f"Password token was not passed from server.")
auth_file = s.pool.account.derived_auth_file
_write_encoded_auth_value(auth_file, to_encode[0], overwrite)

def write_pam_interactive_irodsA_file(overwrite=True, ttl="", **kw):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Let's be sure to test this routine works as expected, too. Feel free to ping me about the writing of the tests as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

4 participants