Repository: fedora-infra/bodhi

Issue:
Add a kerberos-based login method in the client
The Packit folks have written an extension to the Bodhi Client to support kerberos authentication:

https://github.com/packit/packit/issues/1624#issuecomment-1161676764

It's worth considering it for integration in Bodhi Client itself.


Discussion:
I can contribute that code here if interested.

Resolving patch:
diff --git a/bodhi-client/bodhi/client/bindings.py b/bodhi-client/bodhi/client/bindings.py
--- a/bodhi-client/bodhi/client/bindings.py
+++ b/bodhi-client/bodhi/client/bindings.py
@@ -41,9 +41,9 @@
     # dnf is not available on EL 7.
     dnf = None  # pragma: no cover
 from munch import munchify
-from requests.exceptions import RequestException, ConnectionError
-import requests
+from requests.exceptions import ConnectionError, RequestException
 import koji
+import requests
 
 from .constants import (
     BASE_URL,
@@ -237,7 +237,7 @@ def send_request(self, url, verb="GET", **kwargs):
 
     def ensure_auth(self):
         """Make sure we are authenticated."""
-        self.oidc.ensure_auth()
+        self.oidc.ensure_auth(use_kerberos=True)
         if not self.oidc.has_cookie("auth_tkt", domain=urlparse(self.base_url).hostname):
             while True:
                 resp = self.oidc.request("GET", f"{self.base_url}oidc/login-token")
@@ -245,7 +245,7 @@ def ensure_auth(self):
                     break
                 if resp.status_code == 401:
                     self.clear_auth()
-                    self.oidc.login()
+                    self.oidc.login(use_kerberos=True)
                 else:
                     resp.raise_for_status()
 
diff --git a/bodhi-client/bodhi/client/oidcclient.py b/bodhi-client/bodhi/client/oidcclient.py
--- a/bodhi-client/bodhi/client/oidcclient.py
+++ b/bodhi-client/bodhi/client/oidcclient.py
@@ -1,14 +1,15 @@
 """A generic OIDC client that can use OOB or not."""
-
 from functools import partial
 from http.server import BaseHTTPRequestHandler, HTTPServer
 import json
 import os
+import re
 import threading
 
 from authlib.integrations.base_client.errors import OAuthError
 from authlib.integrations.requests_client import OAuth2Session
 from authlib.oidc.discovery.well_known import get_well_known_url
+from requests_kerberos import HTTPKerberosAuth, OPTIONAL
 import click
 import requests
 
@@ -45,7 +46,7 @@ class OIDCClient:
     """A client for OpenID Connect authentication."""
 
     def __init__(
-        self, client_id, scope, id_provider, storage
+        self, client_id, scope, id_provider, storage,
     ):
         """Initialize OIDCClient.
 
@@ -117,16 +118,46 @@ def username(self):
                 self._username = response.json()["nickname"]
         return self._username
 
-    def login(self):
+    def login(self, use_kerberos=False):
         """Login to the OIDC provider.
 
         If authentication fails, it will be retried.
 
+        Args:
+            use_kerberos (bool): Use Kerberos for authentication.
+
         Raises:
             click.ClickException: When authentication was cancelled.
         """
         authorization_endpoint = self.metadata["authorization_endpoint"]
         uri, state_ = self.client.create_authorization_url(authorization_endpoint)
+        # 1. use_kerberos is True and Kerberos succeeds -> print success
+        # 2. use_kerberos is True and Kerberos fails -> browser login follows
+        # 3. use_kerberos is False -> browser login only
+        if use_kerberos:
+            try:
+                self.login_with_kerberos(uri)
+            except (OIDCClientError, OAuthError) as e:
+                click.secho(
+                    f"Kerberos authentication failed ({e}). "
+                    f"Proceeding with browser-based authentication.",
+                    fg="red"
+                )
+        if not self.is_logged_in:
+            self.login_with_browser(uri)
+        click.secho("Login successful!", fg="green")
+
+    def login_with_browser(self, uri):
+        """Login to the OIDC provider using the browser.
+
+        If authentication failed, it will be retried.
+
+        Args:
+            uri (str): Authentication URL as obtained from ``create_authorization_url()``.
+
+        Raises:
+            click.ClickException: Login has been cancelled.
+        """
         click.secho("Authenticating... Please open your browser to:", fg="yellow")
         click.echo(uri)
         if self._use_oob:
@@ -147,7 +178,34 @@ def login(self):
                     break
         else:
             self._run_http_server()
-        click.secho("Login successful!", fg="green")
+
+    def login_with_kerberos(self, uri):
+        """Login to the OIDC provider using Kerberos.
+
+        Raises:
+            OIDCClientError: if there is a problem during the auth process.
+        """
+        response = requests.get(
+            uri,
+            auth=HTTPKerberosAuth(
+                # REQUIRED is not working with id.fedoraproject.org
+                mutual_authentication=OPTIONAL,
+            ),
+        )
+        try:
+            response.raise_for_status()
+        except requests.RequestException as e:
+            raise OIDCClientError(
+                f"There was an issue while performing Kerberos authentication: {e}")
+        try:
+            value = re.findall(
+                r"<title>\s*(code=[\w\-_=;&]+)\s*</title>", response.text
+            )[0]
+        except IndexError:
+            raise OIDCClientError(
+                f'Unable to locate OIDC code in the response from "{uri}".'
+            )
+        self.auth_callback(f"?{value}")
 
     def _run_http_server(self):
         httpd = HTTPServer(
@@ -179,10 +237,15 @@ def auth_callback(self, response):
             redirect_uri=self.redirect_uri,
         )
 
-    def ensure_auth(self):
+    @property
+    def is_logged_in(self):
+        """Check whether the client is logged in with the provider."""
+        return bool(self.tokens)
+
+    def ensure_auth(self, use_kerberos=False):
         """Make sure the client is authenticated."""
-        if not self.tokens:
-            self.login()
+        if not self.is_logged_in:
+            self.login(use_kerberos=use_kerberos)
 
     @property
     def tokens(self):


Test patch:
diff --git a/bodhi-client/tests/test_bindings.py b/bodhi-client/tests/test_bindings.py
--- a/bodhi-client/tests/test_bindings.py
+++ b/bodhi-client/tests/test_bindings.py
@@ -67,7 +67,7 @@ def test_id_provider(self, mocker):
             "CLIENT_ID",
             constants.SCOPE,
             "https://id.example.com",
-            storage=storage.return_value
+            storage=storage.return_value,
         )
         storage.assert_called_with(os.path.expanduser("~/.config/bodhi/client.json"))
         assert client.csrf_token == ''
@@ -106,7 +106,7 @@ def test_ensure_auth_ok(self):
 
         client.ensure_auth()
 
-        self.oidc.ensure_auth.assert_called_once_with()
+        self.oidc.ensure_auth.assert_called_once_with(use_kerberos=True)
         self.oidc.request.assert_called_once_with(
             "GET", "http://example.com/bodhi/oidc/login-token"
         )
@@ -161,7 +161,7 @@ def test_send_request_with_auth(self, mocker):
         client.send_request("somewhere", "GET", auth=True)
 
         self.oidc.request.assert_called_once_with("GET", "http://example.com/bodhi/somewhere")
-        self.oidc.ensure_auth.assert_called_once_with()
+        self.oidc.ensure_auth.assert_called_once_with(use_kerberos=True)
 
     def test_send_request_error(self, mocker):
         response = build_response(500, "/url", "error")
diff --git a/bodhi-client/tests/test_oidcclient.py b/bodhi-client/tests/test_oidcclient.py
--- a/bodhi-client/tests/test_oidcclient.py
+++ b/bodhi-client/tests/test_oidcclient.py
@@ -101,6 +101,85 @@ def test_oidcclient_login(mocker, client):
     assert client.tokens == "result-token"
 
 
+def test_oidcclient_login_with_kerberos(mocker, client):
+    oauth2client = mocker.Mock()
+    client.client = oauth2client
+    response = mocker.Mock()
+    sample_code = "code=d37deb2e-5463-1234_5EH4MhV3L&amp;state=k44Rw1"
+    response.text = (
+        '<meta charset="UTF-8">\n<title>' + sample_code + '</title>\n   '
+    )
+    mocker.patch("bodhi.client.oidcclient.requests.get", return_value=response)
+    # Enable OOB
+    client.metadata = {
+        "token_endpoint": "http://id.example.com/token",
+        "authorization_endpoint": "http://id.example.com/auth",
+        "response_modes_supported": ["oob"]
+    }
+    oauth2client.fetch_token.return_value = "result-token"
+    client.login_with_kerberos("auth-url")
+    oauth2client.fetch_token.assert_called_with(
+        'http://id.example.com/token',
+        authorization_response=f"?{sample_code}",
+        redirect_uri='urn:ietf:wg:oauth:2.0:oob',
+    )
+    assert client.tokens == "result-token"
+
+
+def test_oidcclient_login_with_kerberos_wrong_code(mocker, client):
+    oauth2client = mocker.Mock()
+    client.client = oauth2client
+    response = mocker.Mock()
+    sample_code = "code=NOT_4_VALID=STRING@#*"
+    response.text = (
+        '<meta charset="UTF-8">\n<title>' + sample_code + '</title>\n   '
+    )
+    mocker.patch("bodhi.client.oidcclient.requests.get", return_value=response)
+    # Enable OOB
+    client.metadata = {
+        "token_endpoint": "http://id.example.com/token",
+        "authorization_endpoint": "http://id.example.com/auth",
+        "response_modes_supported": ["oob"]
+    }
+    oauth2client.fetch_token.return_value = "result-token"
+    with pytest.raises(OIDCClientError) as exc:
+        client.login_with_kerberos("auth-url")
+    assert str(exc.value) == 'Unable to locate OIDC code in the response from "auth-url".'
+
+
+def test_oidcclient_login_with_kerberos_error_during_auth(mocker, client):
+    oauth2client = mocker.Mock()
+    client.client = oauth2client
+    response = mocker.Mock()
+    response.text = "<response>"
+    mocker.patch("bodhi.client.oidcclient.requests.get", return_value=response)
+    client.metadata = {
+        "token_endpoint": "http://id.example.com/token",
+        "authorization_endpoint": "http://id.example.com/auth",
+        "response_modes_supported": []
+    }
+
+    def raise_http_error():
+        raise requests.HTTPError("error")
+    response.raise_for_status = raise_http_error
+    with pytest.raises(OIDCClientError) as exc:
+        client.login_with_kerberos("auth-url")
+    assert str(exc.value) == 'There was an issue while performing Kerberos authentication: error'
+
+
+def test_oidcclient_login_kerberos_fallback(mocker, client):
+    oauth2client = mocker.Mock()
+    client.client = oauth2client
+    oauth2client.create_authorization_url.return_value = ("auth-url", "state")
+    login_with_kerberos = mocker.patch.object(
+        client, "login_with_kerberos", side_effect=OIDCClientError
+    )
+    login_with_browser = mocker.patch.object(client, "login_with_browser")
+    client.login(use_kerberos=True)
+    login_with_kerberos.assert_called_once_with("auth-url")
+    login_with_browser.assert_called_once_with("auth-url")
+
+
 def test_oidcclient_login_no_oob(mocker, client):
     oauth2client = mocker.Mock()
     client.client = oauth2client
@@ -275,7 +354,7 @@ def test_oidcclient_request_auth_error(mocker, client):
 
     assert result == "OK this time"
     assert request.call_count == 2
-    login.assert_called_with()
+    login.assert_called_with(use_kerberos=False)
     assert client.tokens == {}