Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
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
7 changes: 7 additions & 0 deletions docs/source/config/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,17 @@ Finally, instruct the users to login with the following commands
jmp client login <client alias> --endpoint <jumpstarter controller endpoint> \
--namespace <namespace> --name <client name> \
--issuer https://<keycloak domain>/realms/<realm name>
# without additional options, the users would be directed to login with the web browser
# or the username and password can be directly specified for non-interactive login
--username <username> --password <password>
# or a token for machine to machine authentication, useful in CI environments
--token <token>

# for exporters
jmp exporter login <exporter alias> --endpoint <jumpstarter controller endpoint> \
--namespace <namespace> --name <exporter name> \
--issuer https://<keycloak domain>/realms/<realm name>
# --username, --password and --token are also accepted by jmp exporter login
```

## Reference
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncclick as click
from jumpstarter_cli_common.exceptions import async_handle_exceptions
from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_client_id
from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_client_id, opt_connector_id

from jumpstarter.common.exceptions import FileNotFoundError
from jumpstarter.config import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers, ObjectMeta, UserConfigV1Alpha1
Expand Down Expand Up @@ -29,6 +29,7 @@
)
@click.option("--username", type=str, help="Enter the OIDC username.", default=None)
@click.option("--password", type=str, help="Enter the OIDC password.", default=None)
@click.option("--token", type=str, help="Enter the OIDC token.", default=None)
@click.option(
"--issuer",
type=str,
Expand All @@ -45,6 +46,7 @@
"--unsafe", is_flag=True, help="Should all driver client packages be allowed to load (UNSAFE!).", default=None
)
@opt_client_id
@opt_connector_id
@async_handle_exceptions
async def client_login( # noqa: C901
alias: str,
Expand All @@ -53,8 +55,10 @@ async def client_login( # noqa: C901
name: str,
username: str | None,
password: str | None,
token: str | None,
issuer: str,
client_id: str,
connector_id: str | None,
allow: str,
unsafe: str,
):
Expand Down Expand Up @@ -96,7 +100,10 @@ async def client_login( # noqa: C901

oidc = Config(issuer=issuer, client_id=client_id)

if username is not None and password is not None:
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:
tokens = await oidc.authorization_code_grant()
Expand Down
19 changes: 19 additions & 0 deletions packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
truststore.inject_into_ssl()

opt_client_id = click.option("--client-id", "client_id", type=str, default="jumpstarter-cli", help="OIDC client id")
opt_connector_id = click.option(
"--connector-id", "connector_id", type=str, help="OIDC token exchange connector id (Dex specific)"
)


@dataclass(kw_only=True)
Expand All @@ -34,6 +37,22 @@ async def configuration(self):
def client(self, **kwargs):
return OAuth2Session(client_id=self.client_id, scope=self.scope, **kwargs)

async def token_exchange_grant(self, token: str, **kwargs):
config = await self.configuration()

client = self.client()

return await run_sync(
lambda: client.fetch_token(
config["token_endpoint"],
grant_type="urn:ietf:params:oauth:grant-type:token-exchange",
requested_token_type="urn:ietf:params:oauth:token-type:access_token",
subject_token_type="urn:ietf:params:oauth:token-type:id_token",
subject_token=token,
**kwargs,
)
)

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import asyncclick as click
from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_client_id
from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_client_id, opt_connector_id

from jumpstarter.config.exporter import ExporterConfigV1Alpha1, ObjectMeta

Expand All @@ -11,17 +11,21 @@
@click.option("--name", type=str, help="Enter the Jumpstarter exporter name.", default=None)
@click.option("--username", type=str, help="Enter the OIDC username.", default=None)
@click.option("--password", type=str, help="Enter the OIDC password.", default=None)
@click.option("--token", type=str, help="Enter the OIDC token.", default=None)
@click.option("--issuer", type=str, help="Enter the OIDC issuer.", default=None)
@opt_client_id
@opt_connector_id
async def exporter_login(
alias: str,
endpoint: str,
namespace: str,
name: str,
username: str | None,
password: str | None,
token: str | None,
issuer: str,
client_id: str,
connector_id: str,
):
"""Login into a jumpstarter instance"""
try:
Expand All @@ -47,7 +51,10 @@ async def exporter_login(

oidc = Config(issuer=issuer, client_id=client_id)

if username is not None and password is not None:
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:
tokens = await oidc.authorization_code_grant()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@ def write_local_file(file):

return base


class StorageMuxFlasherClient(FlasherClient, StorageMuxClient):
def flash(
self,
Expand All @@ -621,8 +622,7 @@ def flash(
):
"""Flash image to DUT"""
if partition is not None:
raise ArgumentError(
f"partition is not supported for StorageMuxFlasherClient, {partition} provided")
raise ArgumentError(f"partition is not supported for StorageMuxFlasherClient, {partition} provided")

self.host()

Expand All @@ -644,8 +644,7 @@ def dump(
):
"""Dump image from DUT"""
if partition is not None:
raise ArgumentError(
f"partition is not supported for StorageMuxFlasherClient, {partition} provided")
raise ArgumentError(f"partition is not supported for StorageMuxFlasherClient, {partition} provided")

self.call("host")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ async def read(self, dst: str):
async for chunk in stream:
await res.send(chunk)


Comment thread
NickCao marked this conversation as resolved.
@dataclass
class MockStorageMuxFlasher(StorageMuxFlasherInterface, MockStorageMux):
pass
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,13 @@ def test_driver_flasher(tmp_path, partition):

assert (tmp_path / "dump.img").read_bytes() == b"hello"


def test_driver_mock_storage_mux_flasher(tmp_path):
with serve(MockStorageMuxFlasher()) as flasher:
(tmp_path / "disk.img").write_bytes(b"hello")

# mock the StorageMuxClient dut/host methods
with mock.patch.object(flasher, "call", side_effect=flasher.call) as mock_method:

flasher.flash(tmp_path / "disk.img")
# assert the mock had a call to "host", "write" and "dut"
assert mock_method.call_args_list == [
Expand All @@ -174,6 +174,7 @@ def test_driver_mock_storage_mux_flasher(tmp_path):

assert (tmp_path / "dump.img").read_bytes() == b"hello"


def test_drivers_mock_storage_mux_fs(monkeypatch: pytest.MonkeyPatch):
with serve(MockStorageMux()) as client:
with TemporaryDirectory() as tempdir:
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading