Skip to content

Commit 67be594

Browse files
authored
Merge pull request #163 from bennyz/auth-refresh
add offline access to allow refresh tokens
2 parents ef62dca + e2d2859 commit 67be594

5 files changed

Lines changed: 205 additions & 40 deletions

File tree

e2e/tests.bats

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,18 @@ wait_for_exporter() {
6969
}
7070

7171
@test "can create clients with admin cli" {
72-
jmp admin create client -n "${JS_NAMESPACE}" test-client-oidc --unsafe --out /dev/null \
72+
jmp admin create client -n "${JS_NAMESPACE}" test-client-oidc --unsafe --nointeractive \
7373
--oidc-username dex:test-client-oidc
74-
jmp admin create client -n "${JS_NAMESPACE}" test-client-sa --unsafe --out /dev/null \
74+
jmp admin create client -n "${JS_NAMESPACE}" test-client-sa --unsafe --nointeractive \
7575
--oidc-username dex:system:serviceaccount:"${JS_NAMESPACE}":test-client-sa
7676
jmp admin create client -n "${JS_NAMESPACE}" test-client-legacy --unsafe --save
7777
}
7878

7979
@test "can create exporters with admin cli" {
80-
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-oidc --out /dev/null \
80+
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-oidc --nointeractive \
8181
--oidc-username dex:test-exporter-oidc \
8282
--label example.com/board=oidc
83-
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-sa --out /dev/null \
83+
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-sa --nointeractive \
8484
--oidc-username dex:system:serviceaccount:"${JS_NAMESPACE}":test-exporter-sa \
8585
--label example.com/board=sa
8686
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-legacy --save \

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ def opt_oidc(f):
3131
default=None,
3232
help="Port for OIDC callback server (0=random port)",
3333
)
34+
@click.option(
35+
"--offline-access/--no-offline-access",
36+
default=True,
37+
help="Request offline_access scope (refresh token)",
38+
)
3439
@wraps(f)
3540
def wrapper(*args, **kwds):
3641
return f(*args, **kwds)
@@ -42,6 +47,7 @@ def wrapper(*args, **kwds):
4247
class Config:
4348
issuer: str
4449
client_id: str
50+
offline_access: bool = False
4551
scope: ClassVar[list[str]] = ["openid", "profile"]
4652

4753
async def configuration(self):
@@ -52,8 +58,13 @@ async def configuration(self):
5258
) as response:
5359
return await response.json()
5460

61+
def _scopes(self) -> list[str]:
62+
if self.offline_access:
63+
return [*self.scope, "offline_access"]
64+
return list(self.scope)
65+
5566
def client(self, **kwargs):
56-
return OAuth2Session(client_id=self.client_id, scope=self.scope, **kwargs)
67+
return OAuth2Session(client_id=self.client_id, scope=self._scopes(), **kwargs)
5768

5869
async def token_exchange_grant(self, token: str, **kwargs):
5970
config = await self.configuration()
@@ -71,6 +82,19 @@ async def token_exchange_grant(self, token: str, **kwargs):
7182
)
7283
)
7384

85+
async def refresh_token_grant(self, refresh_token: str):
86+
config = await self.configuration()
87+
88+
client = self.client()
89+
90+
return await run_sync(
91+
lambda: client.fetch_token(
92+
config["token_endpoint"],
93+
grant_type="refresh_token",
94+
refresh_token=refresh_token,
95+
)
96+
)
97+
7498
async def password_grant(self, username: str, password: str):
7599
config = await self.configuration()
76100

python/packages/jumpstarter-cli/jumpstarter_cli/auth.py

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
from datetime import datetime, timezone
22

33
import click
4+
from jumpstarter_cli_common.blocking import blocking
45
from jumpstarter_cli_common.config import opt_config
56
from jumpstarter_cli_common.oidc import (
67
TOKEN_EXPIRY_WARNING_SECONDS,
8+
Config,
79
decode_jwt,
10+
decode_jwt_issuer,
811
format_duration,
912
get_token_remaining_seconds,
1013
)
1114

15+
from jumpstarter.config.client import ClientConfigV1Alpha1
16+
1217

1318
@click.group()
1419
def auth():
@@ -19,21 +24,52 @@ def _print_token_status(remaining: float) -> None:
1924
"""Print token status message based on remaining time."""
2025
duration = format_duration(remaining)
2126

27+
hint = "Run 'jmp login' to refresh your credentials."
28+
2229
if remaining < 0:
2330
click.echo(click.style(f"Status: EXPIRED ({duration} ago)", fg="red", bold=True))
24-
click.echo(click.style("Run 'jmp login --force' to refresh your credentials.", fg="yellow"))
31+
click.echo(click.style(hint, fg="yellow"))
2532
elif remaining < TOKEN_EXPIRY_WARNING_SECONDS:
2633
click.echo(click.style(f"Status: EXPIRING SOON ({duration} remaining)", fg="red", bold=True))
27-
click.echo(click.style("Run 'jmp login --force' to refresh your credentials.", fg="yellow"))
34+
click.echo(click.style(hint, fg="yellow"))
2835
elif remaining < 3600:
2936
click.echo(click.style(f"Status: Valid ({duration} remaining)", fg="yellow"))
3037
else:
3138
click.echo(click.style(f"Status: Valid ({duration} remaining)", fg="green"))
3239

3340

41+
def _print_subject_issuer(payload: dict) -> None:
42+
sub = payload.get("sub")
43+
iss = payload.get("iss")
44+
if sub:
45+
click.echo(f"Subject: {sub}")
46+
if iss:
47+
click.echo(f"Issuer: {iss}")
48+
49+
50+
def _print_timestamp(label: str, value: int | None) -> None:
51+
if value is None:
52+
return
53+
dt = datetime.fromtimestamp(value, tz=timezone.utc)
54+
click.echo(f"{label}: {dt.strftime('%Y-%m-%d %H:%M:%S %Z')}")
55+
56+
57+
def _print_verbose_details(payload: dict, config) -> None:
58+
iat = payload.get("iat")
59+
auth_time = payload.get("auth_time")
60+
if isinstance(iat, int):
61+
_print_timestamp("Issued at", iat)
62+
if isinstance(auth_time, int):
63+
_print_timestamp("Auth time", auth_time)
64+
65+
refresh_token = getattr(config, "refresh_token", None)
66+
click.echo(f"Refresh token stored: {'yes' if refresh_token else 'no'}")
67+
68+
3469
@auth.command(name="status")
70+
@click.option("--verbose", is_flag=True, help="Show additional token details")
3571
@opt_config(exporter=False)
36-
def token_status(config):
72+
def token_status(config, verbose: bool):
3773
"""Display token status and expiry information."""
3874
token_str = getattr(config, "token", None)
3975

@@ -58,10 +94,38 @@ def token_status(config):
5894

5995
_print_token_status(remaining)
6096

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}")
97+
_print_subject_issuer(payload)
98+
99+
if verbose:
100+
_print_verbose_details(payload, config)
101+
102+
103+
@auth.command(name="refresh")
104+
@opt_config(exporter=False)
105+
@blocking
106+
async def refresh_token(config):
107+
"""Refresh the access token using a stored refresh token."""
108+
refresh_token = getattr(config, "refresh_token", None)
109+
if not refresh_token:
110+
raise click.ClickException("No refresh token found. Run 'jmp login --offline-access'.")
111+
112+
access_token = getattr(config, "token", None)
113+
if not access_token:
114+
raise click.ClickException("No access token found. Run 'jmp login --offline-access'.")
115+
116+
try:
117+
issuer = decode_jwt_issuer(access_token)
118+
except Exception as e:
119+
raise click.ClickException(f"Failed to decode JWT issuer: {e}") from e
120+
121+
if issuer is None:
122+
raise click.ClickException("Failed to determine issuer from access token.")
123+
124+
oidc = Config(issuer=issuer, client_id="jumpstarter-cli")
125+
tokens = await oidc.refresh_token_grant(refresh_token)
126+
config.token = tokens["access_token"]
127+
new_refresh_token = tokens.get("refresh_token")
128+
if new_refresh_token is not None:
129+
config.refresh_token = new_refresh_token
130+
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
131+
click.echo("Access token refreshed.")

python/packages/jumpstarter-cli/jumpstarter_cli/login.py

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,6 @@
1919
@click.option("-e", "--endpoint", type=str, help="Enter the Jumpstarter service endpoint.", default=None)
2020
@click.option("--namespace", type=str, help="Enter the Jumpstarter exporter namespace.", default=None)
2121
@click.option("--name", type=str, help="Enter the Jumpstarter exporter name.", default=None)
22-
@click.option(
23-
"--force",
24-
is_flag=True,
25-
help="Force fresh login",
26-
default=False,
27-
)
2822
@opt_oidc
2923
# client specific
3024
# TODO: warn if used with exporter
@@ -54,11 +48,11 @@ async def login( # noqa: C901
5448
client_id: str,
5549
connector_id: str,
5650
callback_port: int | None,
51+
offline_access: bool,
5752
unsafe,
5853
insecure_tls_config: bool,
5954
nointeractive: bool,
6055
allow,
61-
force: bool,
6256
):
6357
"""Login into a jumpstarter instance"""
6458

@@ -123,28 +117,55 @@ async def login( # noqa: C901
123117
raise click.UsageError("Issuer is required in non-interactive mode.")
124118
issuer = click.prompt("Enter the OIDC issuer")
125119

126-
oidc = Config(issuer=issuer, client_id=client_id)
120+
stored_refresh_token = getattr(config, "refresh_token", None)
121+
oidc = Config(
122+
issuer=issuer,
123+
client_id=client_id,
124+
offline_access=offline_access or stored_refresh_token is not None,
125+
)
126+
127+
def save_config() -> None:
128+
match config_kind:
129+
case "client":
130+
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
131+
case "client_config":
132+
ClientConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]
133+
case "exporter":
134+
ExporterConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
135+
case "exporter_config":
136+
ExporterConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]
137+
138+
if stored_refresh_token and token is None and username is None and password is None:
139+
try:
140+
tokens = await oidc.refresh_token_grant(stored_refresh_token)
141+
config.token = tokens["access_token"]
142+
refresh_token = tokens.get("refresh_token")
143+
if refresh_token is not None and isinstance(config, ClientConfigV1Alpha1):
144+
config.refresh_token = refresh_token
145+
save_config()
146+
click.echo("Refreshed access token using stored refresh token.")
147+
return
148+
except Exception as e:
149+
if nointeractive:
150+
raise click.ClickException(f"Failed to refresh access token: {e}") from e
151+
pass
127152

128153
if token is not None:
129154
kwargs = {"connector_id": connector_id} if connector_id is not None else {}
130155
tokens = await oidc.token_exchange_grant(token, **kwargs)
131156
elif username is not None and password is not None:
132157
tokens = await oidc.password_grant(username, password)
133158
else:
134-
prompt = "login" if force else None
135-
tokens = await oidc.authorization_code_grant(callback_port=callback_port, prompt=prompt)
159+
tokens = await oidc.authorization_code_grant(callback_port=callback_port)
136160

137161
config.token = tokens["access_token"]
162+
refresh_token = tokens.get("refresh_token")
138163

139-
match config_kind:
140-
case "client":
141-
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
142-
case "client_config":
143-
ClientConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]
144-
case "exporter":
145-
ExporterConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
146-
case "exporter_config":
147-
ExporterConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]
164+
# only client configs support refresh_token
165+
if refresh_token is not None and isinstance(config, ClientConfigV1Alpha1):
166+
config.refresh_token = refresh_token
167+
168+
save_config()
148169

149170

150171
@blocking
@@ -157,9 +178,24 @@ async def relogin_client(config: ClientConfigV1Alpha1):
157178
raise ReauthenticationFailed(f"Failed to decode JWT issuer: {e}") from e
158179

159180
try:
160-
oidc = Config(issuer=issuer, client_id=client_id)
181+
oidc = Config(issuer=issuer, client_id=client_id, offline_access=config.refresh_token is not None)
182+
if config.refresh_token:
183+
try:
184+
tokens = await oidc.refresh_token_grant(config.refresh_token)
185+
config.token = tokens["access_token"]
186+
refresh_token = tokens.get("refresh_token")
187+
if refresh_token is not None:
188+
config.refresh_token = refresh_token
189+
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
190+
return
191+
except Exception:
192+
pass
193+
161194
tokens = await oidc.authorization_code_grant()
162195
config.token = tokens["access_token"]
196+
refresh_token = tokens.get("refresh_token")
197+
if refresh_token is not None:
198+
config.refresh_token = refresh_token
163199
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
164200
except Exception as e:
165201
raise ReauthenticationFailed(f"Failed to re-authenticate: {e}") from e

0 commit comments

Comments
 (0)