diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 0bfc678..18dab0e 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -1,6 +1,8 @@ from __future__ import annotations import argparse +import base64 +import json import sys from kognic.auth import DEFAULT_HOST @@ -49,10 +51,33 @@ def register_parser(subparsers: argparse._SubParsersAction) -> argparse.Argument default=None, help="OAuth2 scopes to request, e.g. --scopes api:read api:write", ) + token_parser.add_argument( + "--decode", + action="store_true", + default=False, + help="Decode and pretty-print the JWT header, payload, and signature instead of printing the raw token", + ) return token_parser +def _decode_jwt_part(part: str) -> dict: + padded = part + "=" * (-len(part) % 4) + return json.loads(base64.urlsafe_b64decode(padded)) + + +def _decode_jwt(token: str) -> str: + parts = token.split(".") + if len(parts) != 3: + raise ValueError("Token is not a valid JWT (expected 3 dot-separated parts)") + decoded = { + "header": _decode_jwt_part(parts[0]), + "payload": _decode_jwt_part(parts[1]), + "signature": parts[2], + } + return json.dumps(decoded, indent=2) + + def run(parsed: argparse.Namespace) -> int: try: host = parsed.server @@ -84,7 +109,11 @@ def run(parsed: argparse.Namespace) -> int: token_cache=make_cache(parsed.token_cache), scopes=scopes, ) - print(provider.ensure_token()["access_token"]) + token = provider.ensure_token()["access_token"] + if parsed.decode: + print(_decode_jwt(token)) + else: + print(token) return 0 except FileNotFoundError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/tests/test_cli.py b/tests/test_cli.py index b274948..227d017 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -297,6 +297,35 @@ def test_main_with_unknown_context(self): self.assertEqual(result, 1) self.assertIn("nonexistent", mock_print.call_args[0][0]) + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_decode_jwt(self, mock_make_provider): + import base64 + + payload = base64.urlsafe_b64encode(json.dumps({"sub": "user123", "exp": 9999999999}).encode()).rstrip(b"=") + jwt_token = f"eyJhbGciOiJSUzI1NiJ9.{payload.decode()}.fakesignature" + mock_make_provider.return_value = self._make_provider(jwt_token) + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token", "--decode", "--token-cache", "none"]) + + self.assertEqual(result, 0) + printed = mock_print.call_args[0][0] + decoded = json.loads(printed) + self.assertEqual(decoded["header"], {"alg": "RS256"}) + self.assertEqual(decoded["payload"]["sub"], "user123") + self.assertEqual(decoded["payload"]["exp"], 9999999999) + self.assertEqual(decoded["signature"], "fakesignature") + + @mock.patch("kognic.auth.cli.get_access_token.make_token_provider") + def test_main_decode_invalid_jwt(self, mock_make_provider): + mock_make_provider.return_value = self._make_provider("not-a-jwt") + + with mock.patch("builtins.print") as mock_print: + result = main(["get-access-token", "--decode", "--token-cache", "none"]) + + self.assertEqual(result, 1) + self.assertIn("Error:", mock_print.call_args[0][0]) + class CliCacheTest(unittest.TestCase): """Tests for token caching in get-access-token."""