Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit bf08fd9

Browse files
authored
Merge pull request #791 from bennyz/token-status
cli: add auth status
2 parents ac364c4 + 71379d8 commit bf08fd9

4 files changed

Lines changed: 217 additions & 5 deletions

File tree

packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import os
3+
import time
34
from dataclasses import dataclass
45
from functools import wraps
56
from typing import ClassVar
@@ -23,7 +24,8 @@ def opt_oidc(f):
2324
@click.option("--username", help="OIDC username")
2425
@click.option("--password", help="OIDC password")
2526
@click.option("--connector-id", "connector_id", help="OIDC token exchange connector id (Dex specific)")
26-
@click.option("--callback-port",
27+
@click.option(
28+
"--callback-port",
2729
"callback_port",
2830
type=click.IntRange(0, 65535),
2931
default=None,
@@ -93,7 +95,7 @@ async def authorization_code_grant(self, callback_port: int | None = None):
9395
elif env_value.isdigit() and int(env_value) <= 65535:
9496
port = int(env_value)
9597
else:
96-
raise click.ClickException(f"Invalid {JMP_OIDC_CALLBACK_PORT} \"{env_value}\": must be a valid port")
98+
raise click.ClickException(f'Invalid {JMP_OIDC_CALLBACK_PORT} "{env_value}": must be a valid port')
9799

98100
tx, rx = create_memory_object_stream()
99101

@@ -133,8 +135,75 @@ async def callback(request):
133135

134136

135137
def decode_jwt(token: str):
136-
return json.loads(extract_compact(token.encode()).payload)
138+
try:
139+
return json.loads(extract_compact(token.encode()).payload)
140+
except (ValueError, KeyError, TypeError) as e:
141+
raise ValueError(f"Invalid JWT format: {e}") from e
137142

138143

139144
def decode_jwt_issuer(token: str):
140145
return decode_jwt(token).get("iss")
146+
147+
148+
def get_token_expiry(token: str) -> int | None:
149+
"""Get token expiry timestamp (Unix epoch seconds) from JWT.
150+
151+
Returns None if token doesn't have an exp claim.
152+
"""
153+
return decode_jwt(token).get("exp")
154+
155+
156+
def get_token_remaining_seconds(token: str) -> float | None:
157+
"""Get seconds remaining until token expires.
158+
159+
Returns:
160+
Positive value if token is still valid
161+
Negative value if token is expired (magnitude = how long ago)
162+
None if token doesn't have an exp claim
163+
"""
164+
exp = get_token_expiry(token)
165+
if exp is None:
166+
return None
167+
return exp - time.time()
168+
169+
170+
# Token expiry warning threshold in seconds (5 minutes)
171+
TOKEN_EXPIRY_WARNING_SECONDS = 300
172+
173+
174+
def is_token_expired(token: str, buffer_seconds: int = 0) -> bool:
175+
"""Check if token is expired or will expire within buffer_seconds.
176+
177+
Args:
178+
token: JWT token string
179+
buffer_seconds: Consider expired if less than this many seconds remain
180+
181+
Returns:
182+
True if token is expired or will expire within buffer
183+
False if token is still valid (or has no exp claim)
184+
"""
185+
remaining = get_token_remaining_seconds(token)
186+
if remaining is None:
187+
return False
188+
return remaining < buffer_seconds
189+
190+
191+
def format_duration(seconds: float) -> str:
192+
"""Format a duration in seconds as a human-readable string.
193+
194+
Args:
195+
seconds: Duration in seconds (can be negative for past times)
196+
197+
Returns:
198+
Formatted string like "2h 30m" or "5m 10s"
199+
"""
200+
abs_seconds = abs(seconds)
201+
hours = int(abs_seconds // 3600)
202+
mins = int((abs_seconds % 3600) // 60)
203+
secs = int(abs_seconds % 60)
204+
205+
if hours > 0:
206+
return f"{hours}h {mins}m"
207+
if mins > 0:
208+
return f"{mins}m {secs}s"
209+
return f"{secs}s"
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from datetime import datetime, timezone
2+
3+
import click
4+
from jumpstarter_cli_common.config import opt_config
5+
from jumpstarter_cli_common.oidc import (
6+
TOKEN_EXPIRY_WARNING_SECONDS,
7+
decode_jwt,
8+
format_duration,
9+
get_token_remaining_seconds,
10+
)
11+
12+
13+
@click.group()
14+
def auth():
15+
"""Authentication and token management commands."""
16+
17+
18+
def _print_token_status(remaining: float) -> None:
19+
"""Print token status message based on remaining time."""
20+
duration = format_duration(remaining)
21+
22+
if remaining < 0:
23+
click.echo(click.style(f"Status: EXPIRED ({duration} ago)", fg="red", bold=True))
24+
click.echo(click.style("Run 'jmp login' to refresh your credentials.", fg="yellow"))
25+
elif remaining < TOKEN_EXPIRY_WARNING_SECONDS:
26+
click.echo(click.style(f"Status: EXPIRING SOON ({duration} remaining)", fg="red", bold=True))
27+
click.echo(click.style("Run 'jmp login' to refresh your credentials.", fg="yellow"))
28+
elif remaining < 3600:
29+
click.echo(click.style(f"Status: Valid ({duration} remaining)", fg="yellow"))
30+
else:
31+
click.echo(click.style(f"Status: Valid ({duration} remaining)", fg="green"))
32+
33+
34+
@auth.command(name="status")
35+
@opt_config(exporter=False)
36+
def token_status(config):
37+
"""Display token status and expiry information."""
38+
token_str = getattr(config, "token", None)
39+
40+
if not token_str:
41+
click.echo(click.style("No token found in config", fg="yellow"))
42+
return
43+
44+
try:
45+
payload = decode_jwt(token_str)
46+
except ValueError as e:
47+
click.echo(click.style(f"Failed to decode token: {e}", fg="red"))
48+
return
49+
50+
remaining = get_token_remaining_seconds(token_str)
51+
if remaining is None:
52+
click.echo(click.style("Token has no expiry claim", fg="yellow"))
53+
return
54+
55+
exp = payload.get("exp")
56+
exp_dt = datetime.fromtimestamp(exp, tz=timezone.utc)
57+
click.echo(f"Token expiry: {exp_dt.strftime('%Y-%m-%d %H:%M:%S %Z')}")
58+
59+
_print_token_status(remaining)
60+
61+
# Show additional token info
62+
sub = payload.get("sub")
63+
iss = payload.get("iss")
64+
if sub:
65+
click.echo(f"Subject: {sub}")
66+
if iss:
67+
click.echo(f"Issuer: {iss}")

packages/jumpstarter-cli/jumpstarter_cli/jmp.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from jumpstarter_cli_common.version import version
66
from jumpstarter_cli_driver import driver
77

8+
from .auth import auth
89
from .config import config
910
from .create import create
1011
from .delete import delete
@@ -21,6 +22,7 @@ def jmp():
2122
"""The Jumpstarter CLI"""
2223

2324

25+
jmp.add_command(auth)
2426
jmp.add_command(create)
2527
jmp.add_command(delete)
2628
jmp.add_command(update)

packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
from anyio import create_task_group, get_cancelled_exc_class
77
from jumpstarter_cli_common.config import opt_config
88
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
9+
from jumpstarter_cli_common.oidc import (
10+
TOKEN_EXPIRY_WARNING_SECONDS,
11+
format_duration,
12+
get_token_remaining_seconds,
13+
)
914
from jumpstarter_cli_common.signal import signal_handler
1015

1116
from .common import opt_acquisition_timeout, opt_duration_partial, opt_selector
@@ -15,12 +20,59 @@
1520
from jumpstarter.config.exporter import ExporterConfigV1Alpha1
1621

1722

23+
def _warn_about_expired_token(lease_name: str, selector: str) -> None:
24+
"""Warn user that lease won't be cleaned up due to expired token."""
25+
click.echo(click.style("\nToken expired - lease cleanup will fail.", fg="yellow", bold=True))
26+
click.echo(click.style(f"Lease '{lease_name}' will remain active.", fg="yellow"))
27+
click.echo(click.style(f"To reconnect: JMP_LEASE={lease_name} jmp shell", fg="cyan"))
28+
29+
30+
async def _monitor_token_expiry(config, cancel_scope) -> None:
31+
"""Monitor token expiry and warn user."""
32+
token = getattr(config, "token", None)
33+
if not token:
34+
return
35+
36+
warned = False
37+
while not cancel_scope.cancel_called:
38+
try:
39+
remaining = get_token_remaining_seconds(token)
40+
if remaining is None:
41+
return
42+
43+
if remaining <= 0:
44+
click.echo(click.style("\nToken expired! Exiting shell.", fg="red", bold=True))
45+
cancel_scope.cancel()
46+
return
47+
48+
if remaining <= TOKEN_EXPIRY_WARNING_SECONDS and not warned:
49+
duration = format_duration(remaining)
50+
click.echo(
51+
click.style(
52+
f"\nToken expires in {duration}. Session will continue but cleanup may fail on exit.",
53+
fg="yellow",
54+
bold=True,
55+
)
56+
)
57+
warned = True
58+
59+
await anyio.sleep(30)
60+
except Exception:
61+
return
62+
63+
1864
def _run_shell_with_lease(lease, exporter_logs, config, command):
1965
"""Run shell with lease context managers."""
66+
2067
def launch_remote_shell(path: str) -> int:
2168
return launch_shell(
22-
path, lease.exporter_name, config.drivers.allow, config.drivers.unsafe,
23-
config.shell.use_profiles, command=command, lease=lease
69+
path,
70+
lease.exporter_name,
71+
config.drivers.allow,
72+
config.drivers.unsafe,
73+
config.shell.use_profiles,
74+
command=command,
75+
lease=lease,
2476
)
2577

2678
with lease.serve_unix() as path:
@@ -39,13 +91,28 @@ async def _shell_with_signal_handling(
3991
"""Handle lease acquisition and shell execution with signal handling."""
4092
exit_code = 0
4193
cancelled_exc_class = get_cancelled_exc_class()
94+
lease_used = None
95+
96+
# Check token before starting
97+
token = getattr(config, "token", None)
98+
if token:
99+
remaining = get_token_remaining_seconds(token)
100+
if remaining is not None and remaining <= 0:
101+
from jumpstarter.common.exceptions import ConnectionError
102+
raise ConnectionError("token is expired")
42103

43104
async with create_task_group() as tg:
44105
tg.start_soon(signal_handler, tg.cancel_scope)
106+
45107
try:
46108
try:
47109
async with anyio.from_thread.BlockingPortal() as portal:
48110
async with config.lease_async(selector, lease_name, duration, portal, acquisition_timeout) as lease:
111+
lease_used = lease
112+
113+
# Start token monitoring only once we're in the shell
114+
tg.start_soon(_monitor_token_expiry, config, tg.cancel_scope)
115+
49116
exit_code = await anyio.to_thread.run_sync(
50117
_run_shell_with_lease, lease, exporter_logs, config, command
51118
)
@@ -55,6 +122,13 @@ async def _shell_with_signal_handling(
55122
raise exc from None
56123
raise
57124
except cancelled_exc_class:
125+
# Check if cancellation was due to token expiry
126+
token = getattr(config, "token", None)
127+
if lease_used and token:
128+
remaining = get_token_remaining_seconds(token)
129+
if remaining is not None and remaining <= 0:
130+
_warn_about_expired_token(lease_used.name, selector)
131+
return 3 # Exit code for token expiry
58132
exit_code = 2
59133
finally:
60134
if not tg.cancel_scope.cancel_called:

0 commit comments

Comments
 (0)