Skip to content

Commit 110c3fb

Browse files
authored
Merge pull request #92 from lsst/tickets/DM-48912
Add refresh function in panda_auth
2 parents 7f167d0 + 8e767e3 commit 110c3fb

7 files changed

Lines changed: 267 additions & 5 deletions

File tree

doc/changes/DM-48912.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added refresh function in panda_auth

python/lsst/ctrl/bps/panda/cli/cmd/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
# You should have received a copy of the GNU General Public License
2626
# along with this program. If not, see <https://www.gnu.org/licenses/>.
2727

28-
__all__ = ["clean", "reset", "status"]
28+
__all__ = ["clean", "reset", "refresh", "status"]
2929

30-
from .panda_auth_commands import clean, reset, status
30+
from .panda_auth_commands import clean, reset, refresh, status

python/lsst/ctrl/bps/panda/cli/cmd/panda_auth_commands.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
__all__ = [
3030
"clean",
31+
"refresh",
3132
"reset",
3233
"status",
3334
]
@@ -37,7 +38,12 @@
3738

3839
from lsst.daf.butler.cli.utils import MWCommand
3940

40-
from ...panda_auth_drivers import panda_auth_clean_driver, panda_auth_reset_driver, panda_auth_status_driver
41+
from ...panda_auth_drivers import (
42+
panda_auth_clean_driver,
43+
panda_auth_refresh_driver,
44+
panda_auth_reset_driver,
45+
panda_auth_status_driver,
46+
)
4147

4248

4349
class PandaAuthCommand(MWCommand):
@@ -62,3 +68,13 @@ def reset(*args, **kwargs):
6268
def clean(*args, **kwargs):
6369
"""Clean up token and token cache files."""
6470
panda_auth_clean_driver(*args, **kwargs)
71+
72+
73+
@click.command(cls=PandaAuthCommand)
74+
@click.option("--days", default=4, help="The earlist remaining days to refresh the token.")
75+
@click.option("--verbose", is_flag=True, help="Enable verbose output")
76+
def refresh(*args, **kwargs):
77+
"""Refresh auth tocken."""
78+
days = kwargs.get("days", 4)
79+
verbose = kwargs.get("verbose", False)
80+
panda_auth_refresh_driver(days, verbose)

python/lsst/ctrl/bps/panda/panda_auth_drivers.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
__all__ = [
3535
"panda_auth_clean_driver",
36+
"panda_auth_refresh_driver",
3637
"panda_auth_reset_driver",
3738
"panda_auth_status_driver",
3839
]
@@ -41,7 +42,19 @@
4142
import logging
4243
from datetime import datetime
4344

44-
from .panda_auth_utils import panda_auth_clean, panda_auth_status, panda_auth_update
45+
from lsst.ctrl.bps.panda.panda_exceptions import (
46+
PandaAuthError,
47+
TokenExpiredError,
48+
TokenNotFoundError,
49+
TokenTooEarlyError,
50+
)
51+
52+
from .panda_auth_utils import (
53+
panda_auth_clean,
54+
panda_auth_refresh,
55+
panda_auth_status,
56+
panda_auth_update,
57+
)
4558

4659
_LOG = logging.getLogger(__name__)
4760

@@ -56,6 +69,20 @@ def panda_auth_reset_driver():
5669
panda_auth_update(None, True)
5770

5871

72+
def panda_auth_refresh_driver(days, verbose):
73+
"""Refresh auth token."""
74+
try:
75+
panda_auth_refresh(days, verbose)
76+
except TokenNotFoundError as e:
77+
print(f"[ERROR] {e}")
78+
except TokenExpiredError as e:
79+
print(f"[ERROR] {e}")
80+
except TokenTooEarlyError as e:
81+
print(f"[INFO] {e}")
82+
except PandaAuthError as e:
83+
print(f"[FAIL] {e}")
84+
85+
5986
def panda_auth_status_driver():
6087
"""Gather information about a token if it exists."""
6188
status = panda_auth_status()

python/lsst/ctrl/bps/panda/panda_auth_utils.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,32 @@
3030
__all__ = [
3131
"panda_auth_clean",
3232
"panda_auth_expiration",
33+
"panda_auth_refresh",
3334
"panda_auth_setup",
3435
"panda_auth_status",
3536
"panda_auth_update",
3637
]
3738

3839

40+
import base64
41+
import json
3942
import logging
4043
import os
44+
from datetime import UTC, datetime, timedelta
4145

4246
import idds.common.utils as idds_utils
4347
import pandaclient.idds_api
4448
from pandaclient.openidc_utils import OpenIdConnect_Utils
4549

50+
from lsst.ctrl.bps.panda.panda_exceptions import (
51+
AuthConfigError,
52+
PandaAuthError,
53+
TokenExpiredError,
54+
TokenNotFoundError,
55+
TokenRefreshError,
56+
TokenTooEarlyError,
57+
)
58+
4659
_LOG = logging.getLogger(__name__)
4760

4861

@@ -151,3 +164,87 @@ def panda_auth_update(idds_server=None, reset=False):
151164
# idds server given. So for now, check result string for keywords.
152165
if "request_id" not in ret[1][-1] or "status" not in ret[1][-1]:
153166
raise RuntimeError(f"Error contacting PanDA service: {ret}")
167+
168+
169+
def panda_auth_refresh(days=4, verbose=False):
170+
"""
171+
Refresh the current valid IAM OpenID authentication token.
172+
173+
This function checks the expiration time of the existing token stored
174+
in the local token file and attempts to refresh it if it is close to
175+
expiring (within a specified number of days).
176+
177+
Parameters
178+
----------
179+
days : `int`, optional
180+
The minimum number of days before token expiration to trigger a
181+
refresh. If the token expires in more than this number of days,
182+
the refresh is skipped. Default is 4.
183+
verbose : `bool`, optional
184+
If True, enables verbose output for debugging or logging.
185+
Default is False.
186+
187+
Returns
188+
-------
189+
status: `dict`
190+
A dictionary containing the refreshed token status
191+
"""
192+
panda_url = os.environ.get("PANDA_URL")
193+
panda_auth_vo = os.environ.get("PANDA_AUTH_VO")
194+
195+
if not panda_url or not panda_auth_vo:
196+
raise PandaAuthError("Missing required environment variables: PANDA_URL or PANDA_AUTH_VO")
197+
198+
url_prefix = panda_url.split("/server", 1)[0]
199+
auth_url = f"{url_prefix}/auth/{panda_auth_vo}_auth_config.json"
200+
open_id = OpenIdConnect_Utils(auth_url, log_stream=_LOG, verbose=verbose)
201+
202+
token_file = open_id.get_token_path()
203+
if not os.path.exists(token_file):
204+
raise TokenNotFoundError("Cannot find token file. Use 'panda_auth reset' to obtain a new token.")
205+
206+
with open(token_file) as f:
207+
data = json.load(f)
208+
enc = data["id_token"].split(".")[1]
209+
enc += "=" * (-len(enc) % 4)
210+
dec = json.loads(base64.urlsafe_b64decode(enc.encode()))
211+
exp_time = datetime.fromtimestamp(dec["exp"], tz=UTC)
212+
delta = exp_time - datetime.now(UTC)
213+
minutes = delta.total_seconds() / 60
214+
print(f"Token will expire in {minutes} minutes.")
215+
print(f"Token expiration time : {exp_time.strftime('%Y-%m-%d %H:%M:%S')} UTC")
216+
if delta < timedelta(minutes=0):
217+
raise TokenExpiredError("Token already expired. Cannot refresh.")
218+
elif delta > timedelta(days=days):
219+
raise TokenTooEarlyError(
220+
f"Too early to refresh. More than {days} day(s) until expiration.\n"
221+
f"Use '--days' option to adjust threshold, e.g.:\n"
222+
f" panda_auth refresh --days 10"
223+
)
224+
225+
refresh_token_string = data["refresh_token"]
226+
227+
s, auth_config = open_id.fetch_page(open_id.auth_config_url)
228+
if not s:
229+
raise AuthConfigError("Failed to get Auth configuration.")
230+
231+
s, endpoint_config = open_id.fetch_page(auth_config["oidc_config_url"])
232+
if not s:
233+
raise AuthConfigError("Failed to get endpoint configuration.")
234+
235+
s, o = open_id.refresh_token(
236+
endpoint_config["token_endpoint"],
237+
auth_config["client_id"],
238+
auth_config["client_secret"],
239+
refresh_token_string,
240+
)
241+
242+
if not s:
243+
raise TokenRefreshError("Failed to refresh token.")
244+
245+
status = panda_auth_status()
246+
if status:
247+
exp_time = datetime.fromtimestamp(status["exp"], tz=UTC)
248+
print(f"{'New expiration time:':23} {exp_time.strftime('%Y-%m-%d %H:%M:%S')} UTC")
249+
print("Success to refresh token")
250+
return status
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
class PandaAuthError(Exception):
2+
"""Base class for authentication errors."""
3+
4+
pass
5+
6+
7+
class TokenNotFoundError(PandaAuthError):
8+
"""Raised when the token file is missing."""
9+
10+
pass
11+
12+
13+
class TokenExpiredError(PandaAuthError):
14+
"""Raised when the token has already expired."""
15+
16+
pass
17+
18+
19+
class TokenTooEarlyError(PandaAuthError):
20+
"""Raised when attempting to refresh too early."""
21+
22+
pass
23+
24+
25+
class AuthConfigError(PandaAuthError):
26+
"""Raised when fetching the auth or endpoint configuration fails."""
27+
28+
pass
29+
30+
31+
class TokenRefreshError(PandaAuthError):
32+
"""Raised when token refresh fails."""
33+
34+
pass

tests/test_panda_auth_utils.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,49 @@
2727

2828
"""Unit tests for PanDA authentication utilities."""
2929

30+
import base64
31+
import json
3032
import os
3133
import unittest
34+
from datetime import UTC, datetime, timedelta
3235
from unittest import mock
3336

3437
from lsst.ctrl.bps.panda import __version__ as version
35-
from lsst.ctrl.bps.panda.panda_auth_utils import panda_auth_status
38+
from lsst.ctrl.bps.panda.panda_auth_utils import (
39+
TokenExpiredError,
40+
panda_auth_refresh,
41+
panda_auth_status,
42+
)
43+
44+
45+
def make_fake_jwt(exp_offset_days):
46+
"""Return a fake id_token that expires in N days."""
47+
payload = {"exp": int((datetime.now(UTC) + timedelta(days=exp_offset_days)).timestamp())}
48+
b64_payload = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
49+
return f"header.{b64_payload}.sig"
50+
51+
52+
def fake_token_file(exp_days=3, refresh_token="fake_refresh"):
53+
"""Generate fake token file data"""
54+
token = make_fake_jwt(exp_days)
55+
return json.dumps({"id_token": token, "refresh_token": refresh_token})
56+
57+
58+
def fetch_page_side_effect(url):
59+
"""Simulate OpenIdConnect_Utils.fetch_page behavior in tests."""
60+
if url.endswith("auth_config.json"):
61+
return True, {
62+
"client_secret": "secret",
63+
"audience": "https://iam.example.com",
64+
"client_id": "cid",
65+
"oidc_config_url": "https://oidc.example.org/.well-known/openid-configuration",
66+
"vo": "fake_vo",
67+
"no_verify": "True",
68+
"robot_ids": "NONE",
69+
}
70+
elif url.endswith("openid-configuration"):
71+
return True, {"token_endpoint": "https://oidc.example.org/token"}
72+
return False, {}
3673

3774

3875
class VersionTestCase(unittest.TestCase):
@@ -46,6 +83,21 @@ def test_version(self):
4683
class TestPandaAuthUtils(unittest.TestCase):
4784
"""Simple test of auth utilities."""
4885

86+
def setUp(self):
87+
self.test_env = {
88+
"PANDA_CONFIG_ROOT": "/fake/token",
89+
"PANDA_URL_SSL": "https://fake.server.com:8443/server/panda",
90+
"PANDA_URL": "https://fake.server.com:8443/server/panda",
91+
"PANDACACHE_URL": "https://fake.server.com:8443/server/panda",
92+
"PANDAMON_URL": "https://fake.monitor.com:8443/",
93+
"PANDA_AUTH": "oidc",
94+
"PANDA_VERIFY_HOST": "off",
95+
"PANDA_AUTH_VO": "fake_vo",
96+
"PANDA_BEHIND_REAL_LB": "true",
97+
"PANDA_SYS": "/fake/pandasys",
98+
"IDDS_CONFIG": "/fake/pandasys/etc/idds/idds.cfg.client.template",
99+
}
100+
49101
def testPandaAuthStatusWrongEnviron(self):
50102
unwanted = {
51103
"PANDA_AUTH",
@@ -59,6 +111,41 @@ def testPandaAuthStatusWrongEnviron(self):
59111
with self.assertRaises(OSError):
60112
panda_auth_status()
61113

114+
@mock.patch("builtins.print")
115+
@mock.patch("os.path.exists", return_value=True)
116+
@mock.patch("pandaclient.openidc_utils.OpenIdConnect_Utils")
117+
def test_expired_token(self, mock_oidc, mock_exists, mock_print):
118+
mock_oidc.return_value.get_token_path.return_value = "/fake/token.json"
119+
120+
with mock.patch.dict("os.environ", self.test_env):
121+
with mock.patch("builtins.open", mock.mock_open(read_data=fake_token_file(exp_days=-1))):
122+
with self.assertRaises(TokenExpiredError):
123+
panda_auth_refresh(days=4)
124+
125+
@mock.patch("builtins.print")
126+
@mock.patch("lsst.ctrl.bps.panda.panda_auth_utils.panda_auth_status")
127+
@mock.patch("os.path.exists", return_value=True)
128+
@mock.patch("lsst.ctrl.bps.panda.panda_auth_utils.OpenIdConnect_Utils")
129+
def test_successful_refresh(self, mock_oidc, mock_exists, mock_status, mock_print):
130+
fake_openid = mock_oidc.return_value
131+
fake_openid.get_token_path.return_value = "/fake/token.json"
132+
fake_openid.auth_config_url = "https://fake.server/auth_config.json"
133+
134+
fake_openid.fetch_page.side_effect = fetch_page_side_effect
135+
136+
fake_openid.refresh_token.return_value = (True, {"access_token": "new_token"})
137+
138+
mock_status.return_value = {"exp": int((datetime.now(UTC) + timedelta(seconds=3600)).timestamp())}
139+
140+
with mock.patch.dict("os.environ", self.test_env):
141+
token_json = fake_token_file(exp_days=2)
142+
with mock.patch("builtins.open", mock.mock_open(read_data=token_json)):
143+
panda_auth_refresh(days=4)
144+
145+
fake_openid.refresh_token.assert_called_once()
146+
found = any("Success to refresh token" in str(c[0][0]) for c in mock_print.call_args_list)
147+
assert found
148+
62149

63150
if __name__ == "__main__":
64151
unittest.main()

0 commit comments

Comments
 (0)