Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions e2e/tests.bats
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,18 @@ wait_for_exporter() {
}

@test "can create clients with admin cli" {
jmp admin create client -n "${JS_NAMESPACE}" test-client-oidc --unsafe --out /dev/null \
jmp admin create client -n "${JS_NAMESPACE}" test-client-oidc --unsafe --nointeractive \
--oidc-username dex:test-client-oidc
jmp admin create client -n "${JS_NAMESPACE}" test-client-sa --unsafe --out /dev/null \
jmp admin create client -n "${JS_NAMESPACE}" test-client-sa --unsafe --nointeractive \
--oidc-username dex:system:serviceaccount:"${JS_NAMESPACE}":test-client-sa
jmp admin create client -n "${JS_NAMESPACE}" test-client-legacy --unsafe --save
}

@test "can create exporters with admin cli" {
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-oidc --out /dev/null \
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-oidc --nointeractive \
--oidc-username dex:test-exporter-oidc \
--label example.com/board=oidc
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-sa --out /dev/null \
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-sa --nointeractive \
--oidc-username dex:system:serviceaccount:"${JS_NAMESPACE}":test-exporter-sa \
--label example.com/board=sa
jmp admin create exporter -n "${JS_NAMESPACE}" test-exporter-legacy --save \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def opt_oidc(f):
default=None,
help="Port for OIDC callback server (0=random port)",
)
@click.option(
"--offline-access/--no-offline-access",
default=True,
help="Request offline_access scope (refresh token)",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
@wraps(f)
def wrapper(*args, **kwds):
return f(*args, **kwds)
Expand All @@ -42,6 +47,7 @@ def wrapper(*args, **kwds):
class Config:
issuer: str
client_id: str
offline_access: bool = False
scope: ClassVar[list[str]] = ["openid", "profile"]

async def configuration(self):
Expand All @@ -52,8 +58,13 @@ async def configuration(self):
) as response:
return await response.json()

def _scopes(self) -> list[str]:
if self.offline_access:
return [*self.scope, "offline_access"]
return list(self.scope)

def client(self, **kwargs):
return OAuth2Session(client_id=self.client_id, scope=self.scope, **kwargs)
return OAuth2Session(client_id=self.client_id, scope=self._scopes(), **kwargs)

async def token_exchange_grant(self, token: str, **kwargs):
config = await self.configuration()
Expand All @@ -71,6 +82,19 @@ async def token_exchange_grant(self, token: str, **kwargs):
)
)

async def refresh_token_grant(self, refresh_token: str):
config = await self.configuration()

client = self.client()

return await run_sync(
lambda: client.fetch_token(
config["token_endpoint"],
grant_type="refresh_token",
refresh_token=refresh_token,
)
)

async def password_grant(self, username: str, password: str):
config = await self.configuration()

Expand Down
84 changes: 74 additions & 10 deletions python/packages/jumpstarter-cli/jumpstarter_cli/auth.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
from datetime import datetime, timezone

import click
from jumpstarter_cli_common.blocking import blocking
from jumpstarter_cli_common.config import opt_config
from jumpstarter_cli_common.oidc import (
TOKEN_EXPIRY_WARNING_SECONDS,
Config,
decode_jwt,
decode_jwt_issuer,
format_duration,
get_token_remaining_seconds,
)

from jumpstarter.config.client import ClientConfigV1Alpha1


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

hint = "Run 'jmp login' to refresh your credentials."

if remaining < 0:
click.echo(click.style(f"Status: EXPIRED ({duration} ago)", fg="red", bold=True))
click.echo(click.style("Run 'jmp login --force' to refresh your credentials.", fg="yellow"))
click.echo(click.style(hint, fg="yellow"))
elif remaining < TOKEN_EXPIRY_WARNING_SECONDS:
click.echo(click.style(f"Status: EXPIRING SOON ({duration} remaining)", fg="red", bold=True))
click.echo(click.style("Run 'jmp login --force' to refresh your credentials.", fg="yellow"))
click.echo(click.style(hint, fg="yellow"))
elif remaining < 3600:
Comment thread
bennyz marked this conversation as resolved.
click.echo(click.style(f"Status: Valid ({duration} remaining)", fg="yellow"))
else:
click.echo(click.style(f"Status: Valid ({duration} remaining)", fg="green"))


def _print_subject_issuer(payload: dict) -> None:
sub = payload.get("sub")
iss = payload.get("iss")
if sub:
click.echo(f"Subject: {sub}")
if iss:
click.echo(f"Issuer: {iss}")


def _print_timestamp(label: str, value: int | None) -> None:
if value is None:
return
dt = datetime.fromtimestamp(value, tz=timezone.utc)
click.echo(f"{label}: {dt.strftime('%Y-%m-%d %H:%M:%S %Z')}")


def _print_verbose_details(payload: dict, config) -> None:
iat = payload.get("iat")
auth_time = payload.get("auth_time")
if isinstance(iat, int):
_print_timestamp("Issued at", iat)
if isinstance(auth_time, int):
_print_timestamp("Auth time", auth_time)

refresh_token = getattr(config, "refresh_token", None)
click.echo(f"Refresh token stored: {'yes' if refresh_token else 'no'}")


@auth.command(name="status")
@click.option("--verbose", is_flag=True, help="Show additional token details")
@opt_config(exporter=False)
def token_status(config):
def token_status(config, verbose: bool):
"""Display token status and expiry information."""
token_str = getattr(config, "token", None)

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

_print_token_status(remaining)

# Show additional token info
sub = payload.get("sub")
iss = payload.get("iss")
if sub:
click.echo(f"Subject: {sub}")
if iss:
click.echo(f"Issuer: {iss}")
_print_subject_issuer(payload)

if verbose:
_print_verbose_details(payload, config)


@auth.command(name="refresh")
@opt_config(exporter=False)
@blocking
async def refresh_token(config):
"""Refresh the access token using a stored refresh token."""
refresh_token = getattr(config, "refresh_token", None)
if not refresh_token:
raise click.ClickException("No refresh token found. Run 'jmp login --offline-access'.")

access_token = getattr(config, "token", None)
if not access_token:
raise click.ClickException("No access token found. Run 'jmp login --offline-access'.")

try:
issuer = decode_jwt_issuer(access_token)
except Exception as e:
raise click.ClickException(f"Failed to decode JWT issuer: {e}") from e

if issuer is None:
raise click.ClickException("Failed to determine issuer from access token.")

oidc = Config(issuer=issuer, client_id="jumpstarter-cli")
tokens = await oidc.refresh_token_grant(refresh_token)
config.token = tokens["access_token"]
new_refresh_token = tokens.get("refresh_token")
if new_refresh_token is not None:
config.refresh_token = new_refresh_token
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
click.echo("Access token refreshed.")
76 changes: 56 additions & 20 deletions python/packages/jumpstarter-cli/jumpstarter_cli/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@
@click.option("-e", "--endpoint", type=str, help="Enter the Jumpstarter service endpoint.", default=None)
@click.option("--namespace", type=str, help="Enter the Jumpstarter exporter namespace.", default=None)
@click.option("--name", type=str, help="Enter the Jumpstarter exporter name.", default=None)
@click.option(
"--force",
is_flag=True,
help="Force fresh login",
default=False,
)
@opt_oidc
# client specific
# TODO: warn if used with exporter
Expand Down Expand Up @@ -54,11 +48,11 @@ async def login( # noqa: C901
client_id: str,
connector_id: str,
callback_port: int | None,
offline_access: bool,
unsafe,
insecure_tls_config: bool,
nointeractive: bool,
allow,
force: bool,
):
"""Login into a jumpstarter instance"""

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

oidc = Config(issuer=issuer, client_id=client_id)
stored_refresh_token = getattr(config, "refresh_token", None)
oidc = Config(
issuer=issuer,
client_id=client_id,
offline_access=offline_access or stored_refresh_token is not None,
)
Comment thread
mangelajo marked this conversation as resolved.

def save_config() -> None:
match config_kind:
case "client":
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
case "client_config":
ClientConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]
case "exporter":
ExporterConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
case "exporter_config":
ExporterConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]

if stored_refresh_token and token is None and username is None and password is None:
try:
tokens = await oidc.refresh_token_grant(stored_refresh_token)
config.token = tokens["access_token"]
refresh_token = tokens.get("refresh_token")
if refresh_token is not None and isinstance(config, ClientConfigV1Alpha1):
config.refresh_token = refresh_token
save_config()
click.echo("Refreshed access token using stored refresh token.")
return
except Exception as e:
if nointeractive:
raise click.ClickException(f"Failed to refresh access token: {e}") from e
pass
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if token is not None:
kwargs = {"connector_id": connector_id} if connector_id is not None else {}
tokens = await oidc.token_exchange_grant(token, **kwargs)
elif username is not None and password is not None:
tokens = await oidc.password_grant(username, password)
else:
prompt = "login" if force else None
tokens = await oidc.authorization_code_grant(callback_port=callback_port, prompt=prompt)
tokens = await oidc.authorization_code_grant(callback_port=callback_port)

config.token = tokens["access_token"]
refresh_token = tokens.get("refresh_token")

match config_kind:
case "client":
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
case "client_config":
ClientConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]
case "exporter":
ExporterConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
case "exporter_config":
ExporterConfigV1Alpha1.save(config, value) # ty: ignore[invalid-argument-type]
# only client configs support refresh_token
if refresh_token is not None and isinstance(config, ClientConfigV1Alpha1):
config.refresh_token = refresh_token

save_config()


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

try:
oidc = Config(issuer=issuer, client_id=client_id)
oidc = Config(issuer=issuer, client_id=client_id, offline_access=config.refresh_token is not None)
if config.refresh_token:
try:
tokens = await oidc.refresh_token_grant(config.refresh_token)
config.token = tokens["access_token"]
refresh_token = tokens.get("refresh_token")
if refresh_token is not None:
config.refresh_token = refresh_token
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
return
except Exception:
pass

tokens = await oidc.authorization_code_grant()
config.token = tokens["access_token"]
refresh_token = tokens.get("refresh_token")
if refresh_token is not None:
config.refresh_token = refresh_token
ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type]
except Exception as e:
raise ReauthenticationFailed(f"Failed to re-authenticate: {e}") from e
Loading
Loading