From df38b944089409857c35ce6ede4a8848759201d4 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 24 Mar 2026 15:40:51 +0100 Subject: [PATCH 1/2] Add --decode flag to get-access-token CLI to unpack JWT payload Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/cli/get_access_token.py | 25 ++++++++++++++++++++++- tests/test_cli.py | 27 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 0bfc678..96ddd35 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,27 @@ 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 payload instead of printing the raw token", + ) return token_parser +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)") + payload = parts[1] + # Add padding if needed + payload += "=" * (-len(payload) % 4) + decoded = json.loads(base64.urlsafe_b64decode(payload)) + return json.dumps(decoded, indent=2) + + def run(parsed: argparse.Namespace) -> int: try: host = parsed.server @@ -84,7 +103,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..b62b280 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -297,6 +297,33 @@ 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["sub"], "user123") + self.assertEqual(decoded["exp"], 9999999999) + + @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.""" From f346dc6777a3a5637c8ce27a29d69f01a03fd1ca Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 24 Mar 2026 15:52:15 +0100 Subject: [PATCH 2/2] Include header, payload, and signature in --decode output Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/cli/get_access_token.py | 16 +++++++++++----- tests/test_cli.py | 6 ++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 96ddd35..18dab0e 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -55,20 +55,26 @@ def register_parser(subparsers: argparse._SubParsersAction) -> argparse.Argument "--decode", action="store_true", default=False, - help="Decode and pretty-print the JWT payload instead of printing the raw token", + 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)") - payload = parts[1] - # Add padding if needed - payload += "=" * (-len(payload) % 4) - decoded = json.loads(base64.urlsafe_b64decode(payload)) + decoded = { + "header": _decode_jwt_part(parts[0]), + "payload": _decode_jwt_part(parts[1]), + "signature": parts[2], + } return json.dumps(decoded, indent=2) diff --git a/tests/test_cli.py b/tests/test_cli.py index b62b280..227d017 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -311,8 +311,10 @@ def test_main_decode_jwt(self, mock_make_provider): self.assertEqual(result, 0) printed = mock_print.call_args[0][0] decoded = json.loads(printed) - self.assertEqual(decoded["sub"], "user123") - self.assertEqual(decoded["exp"], 9999999999) + 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):