44#
55"""User management commands"""
66
7+ import base64
8+ import json
9+ import datetime
10+ import time
11+
12+ import click
13+
714from . import Args , catch_error , echo_json , get_api , kci
815
916
@@ -22,3 +29,77 @@ def whoami(config, api, indent, secrets):
2229 api = get_api (config , api , secrets )
2330 user = api .user .whoami ()
2431 echo_json (user , indent )
32+
33+
34+ def _b64url_decode (data : str ) -> bytes :
35+ """Decode base64url data with padding."""
36+ padding = '=' * (- len (data ) % 4 )
37+ return base64 .urlsafe_b64decode (data + padding )
38+
39+
40+ def _decode_jwt (token : str ) -> dict :
41+ """Decode a JWT without verifying signature."""
42+ parts = token .split ('.' )
43+ if len (parts ) != 3 :
44+ raise click .ClickException ("Invalid JWT format; expected 3 parts" )
45+ try :
46+ header = json .loads (_b64url_decode (parts [0 ]).decode ('utf-8' ))
47+ payload = json .loads (_b64url_decode (parts [1 ]).decode ('utf-8' ))
48+ except (ValueError , json .JSONDecodeError ) as exc :
49+ raise click .ClickException ("Invalid JWT encoding" ) from exc
50+ return {"header" : header , "payload" : payload }
51+
52+
53+ @kci_user .command (secrets = True , name = 'token-info' )
54+ @click .option ('--token' , help = "JWT token to decode (defaults to API token)" )
55+ @click .option ('--raw' , is_flag = True , help = "Print raw JSON data" )
56+ @Args .api
57+ @Args .indent
58+ @catch_error
59+ def token_info (token , raw , api , indent , secrets ):
60+ """Decode a JWT and report expiration status"""
61+ token = token or (secrets .api .token if secrets else None )
62+ if not token :
63+ raise click .ClickException (
64+ "No token provided and no API token found; use --token or set "
65+ f"kci.secrets.api.<name>.token and pass --api (current: { api } )"
66+ )
67+
68+ data = _decode_jwt (token )
69+ if api :
70+ data ["api" ] = api
71+ payload = data .get ("payload" , {})
72+ exp = payload .get ("exp" )
73+ now = int (time .time ())
74+ exp_info = None
75+ if isinstance (exp , (int , float )):
76+ exp_int = int (exp )
77+ exp_info = {
78+ "exp" : exp_int ,
79+ "now" : now ,
80+ "expired" : exp_int <= now ,
81+ "expires_in_seconds" : max (0 , exp_int - now ),
82+ }
83+ data ["expiration" ] = exp_info
84+
85+ if raw :
86+ echo_json (data , indent )
87+ return
88+
89+ header = data .get ("header" ) or {}
90+ click .echo ("Token information:" )
91+ if api :
92+ click .echo (f" API config: { api } " )
93+ click .echo (f" Algorithm: { header .get ('alg' , 'unknown' )} " )
94+ click .echo (f" Type: { header .get ('typ' , 'unknown' )} " )
95+
96+ if exp_info is None :
97+ click .echo (" Expiration: not present" )
98+ return
99+
100+ exp_dt = datetime .datetime .utcfromtimestamp (exp_info ["exp" ])
101+ now_dt = datetime .datetime .utcfromtimestamp (exp_info ["now" ])
102+ click .echo (f" Expires (UTC): { exp_dt .isoformat ()} Z" )
103+ click .echo (f" Now (UTC): { now_dt .isoformat ()} Z" )
104+ click .echo (f" Expired: { str (exp_info ['expired' ]).lower ()} " )
105+ click .echo (f" Expires in (seconds): { exp_info ['expires_in_seconds' ]} " )
0 commit comments