diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py index 5403193e7..003b52257 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py @@ -11,16 +11,20 @@ import certifi import click from aiohttp import web -from anyio import create_memory_object_stream +from anyio import create_memory_object_stream, sleep from anyio.to_thread import run_sync +# Suppress AuthlibDeprecationWarning emitted unconditionally during the authlib +# import chain (authlib._joserfc_helpers -> authlib.jose). The project already +# uses joserfc directly for JWT operations; authlib is only needed for +# OAuth2Session. The warning provides no actionable information to end users. warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"authlib\.") from authlib.integrations.requests_client import OAuth2Session # noqa: E402 from joserfc.jws import extract_compact # noqa: E402 from yarl import URL # noqa: E402 -from jumpstarter.config.env import JMP_OIDC_CALLBACK_PORT # noqa: E402 +from jumpstarter.config.env import JMP_OIDC_CALLBACK_PORT, JMP_OIDC_DEVICE_FLOW # noqa: E402 def _get_ssl_context() -> ssl.SSLContext: @@ -50,6 +54,14 @@ def opt_oidc(f): default=True, help="Request offline_access scope (refresh token)", ) + @click.option( + "--device-flow", + "device_flow", + is_flag=True, + default=False, + help="Use OAuth 2.0 Device Authorization Grant (RFC 8628) instead of authorization code flow. " + "Useful in headless or containerized environments where localhost callbacks are not available.", + ) @wraps(f) def wrapper(*args, **kwds): return f(*args, **kwds) @@ -57,6 +69,18 @@ def wrapper(*args, **kwds): return wrapper +def should_use_device_flow(device_flow_flag: bool) -> bool: + """Determine whether to use the device authorization grant flow. + + Returns True if: + - The --device-flow CLI flag was explicitly passed, OR + - The JMP_OIDC_DEVICE_FLOW environment variable is set to "1" + """ + if device_flow_flag: + return True + return os.environ.get(JMP_OIDC_DEVICE_FLOW) == "1" + + @dataclass(kw_only=True) class Config: issuer: str @@ -189,6 +213,117 @@ async def callback(request): lambda: client.fetch_token(config["token_endpoint"], authorization_response=authorization_response) ) + async def device_authorization_grant(self): # noqa: C901 + """Perform OAuth 2.0 Device Authorization Grant (RFC 8628). + + This flow is suitable for headless or containerized environments where + a localhost callback server is not accessible from the user's browser. + + The flow: + 1. Request a device code from the authorization server. + 2. Display a verification URL and user code to the user. + 3. Poll the token endpoint until the user completes authorization. + """ + config = await self.configuration() + + device_endpoint = config.get("device_authorization_endpoint") + if not device_endpoint: + raise click.ClickException( + "The identity provider does not support Device Authorization Grant (RFC 8628). " + "The OIDC discovery document does not include a 'device_authorization_endpoint'. " + "Contact your IdP administrator to enable device flow, or use a different login method." + ) + + token_endpoint = config["token_endpoint"] + + ssl_context: ssl.SSLContext | bool = False if self.insecure_tls else _get_ssl_context() + connector = aiohttp.TCPConnector(ssl=ssl_context) + + async with aiohttp.ClientSession(connector=connector) as session: + # Step 1: Request device authorization + async with session.post( + device_endpoint, + data={ + "client_id": self.client_id, + "scope": " ".join(self._scopes()), + }, + ) as response: + if response.status != 200: + text = await response.text() + raise click.ClickException( + f"Device authorization request failed (HTTP {response.status}): {text}" + ) + try: + device_data = await response.json() + except (aiohttp.ContentTypeError, json.JSONDecodeError) as e: + raise click.ClickException(f"Device authorization response was not valid JSON: {e}") from e + + try: + device_code = device_data["device_code"] + except KeyError as e: + raise click.ClickException( + "Device authorization response is missing the required 'device_code' field." + ) from e + interval = device_data.get("interval", 5) + expires_in = device_data.get("expires_in", 600) + + # Step 2: Display verification URI to user + verification_uri_complete = device_data.get("verification_uri_complete") + if verification_uri_complete: + click.echo(f"To sign in, open the following URL in your browser:\n\n {verification_uri_complete}\n") + else: + verification_uri = device_data.get("verification_uri") + user_code = device_data.get("user_code") + click.echo( + f"To sign in, open the following URL in your browser:\n\n {verification_uri}\n\n" + f"Then enter the code: {user_code}\n" + ) + + click.echo("Waiting for authentication...") + + # Step 3: Poll the token endpoint + deadline = time.monotonic() + expires_in + while time.monotonic() < deadline: + await sleep(interval) + + async with session.post( + token_endpoint, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": self.client_id, + }, + ) as token_response: + token_data = await token_response.json() + + if token_response.status == 200: + return token_data + + error = token_data.get("error", "") + if error == "authorization_pending": + continue + elif error == "slow_down": + interval += 5 + continue + elif error == "expired_token": + raise click.ClickException( + "Device authorization has expired. Please try again." + ) + elif error == "access_denied": + raise click.ClickException( + "Authorization request was denied by the user." + ) + else: + error_description = token_data.get("error_description", "") + raise click.ClickException( + f"Device authorization failed: {error}" + + (f" - {error_description}" if error_description else "") + ) + + raise click.ClickException( + "Device authorization timed out waiting for user approval. Please try again." + ) + def decode_jwt(token: str): try: diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py index 20bdee0e0..3a7382921 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py @@ -1,7 +1,11 @@ import ssl import warnings +from unittest.mock import AsyncMock, MagicMock, patch -from jumpstarter_cli_common.oidc import Config, _get_ssl_context +import click +import pytest + +from jumpstarter_cli_common.oidc import Config, _get_ssl_context, should_use_device_flow class TestConfigInsecureTls: @@ -26,12 +30,270 @@ def test_returns_ssl_context(self) -> None: assert isinstance(ctx, ssl.SSLContext) +class TestShouldUseDeviceFlow: + def test_returns_true_when_flag_is_set(self) -> None: + assert should_use_device_flow(device_flow_flag=True) is True + + def test_returns_true_when_env_var_is_1(self, monkeypatch) -> None: + monkeypatch.setenv("JMP_OIDC_DEVICE_FLOW", "1") + assert should_use_device_flow(device_flow_flag=False) is True + + def test_returns_false_when_env_var_is_not_1(self, monkeypatch) -> None: + monkeypatch.setenv("JMP_OIDC_DEVICE_FLOW", "0") + assert should_use_device_flow(device_flow_flag=False) is False + + def test_returns_false_when_env_var_unset(self, monkeypatch) -> None: + monkeypatch.delenv("JMP_OIDC_DEVICE_FLOW", raising=False) + assert should_use_device_flow(device_flow_flag=False) is False + + def test_flag_takes_priority_over_env(self, monkeypatch) -> None: + monkeypatch.setenv("JMP_OIDC_DEVICE_FLOW", "0") + assert should_use_device_flow(device_flow_flag=True) is True + + +def _make_async_cm(response): + """Create an async context manager wrapper for a MagicMock response.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=response) + cm.__aexit__ = AsyncMock(return_value=False) + return cm + + +class TestDeviceAuthorizationGrant: + @pytest.mark.asyncio + async def test_raises_when_no_device_endpoint_in_discovery(self) -> None: + config = Config(issuer="https://auth.example.com", client_id="test") + with patch.object(config, "configuration", new_callable=AsyncMock) as mock_config: + mock_config.return_value = { + "token_endpoint": "https://auth.example.com/token", + # No device_authorization_endpoint + } + with pytest.raises(click.ClickException, match="does not support Device Authorization Grant"): + await config.device_authorization_grant() + + @pytest.mark.asyncio + async def test_error_message_mentions_device_authorization_endpoint(self) -> None: + config = Config(issuer="https://auth.example.com", client_id="test") + with patch.object(config, "configuration", new_callable=AsyncMock) as mock_config: + mock_config.return_value = { + "token_endpoint": "https://auth.example.com/token", + } + with pytest.raises(click.ClickException, match="device_authorization_endpoint"): + await config.device_authorization_grant() + + @pytest.mark.asyncio + async def test_successful_device_flow_with_verification_uri_complete(self) -> None: + config = Config(issuer="https://auth.example.com", client_id="test") + + discovery = { + "token_endpoint": "https://auth.example.com/token", + "device_authorization_endpoint": "https://auth.example.com/device", + } + + device_response_data = { + "device_code": "test-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://auth.example.com/device", + "verification_uri_complete": "https://auth.example.com/device?user_code=ABCD-EFGH", + "interval": 0.01, # Speed up test + "expires_in": 300, + } + + token_data = { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "token_type": "Bearer", + } + + # Track poll count: first returns authorization_pending, second returns success + poll_count = 0 + + def mock_post(url, data=None, **kwargs): + nonlocal poll_count + response = MagicMock() + + if "device" in str(url) and "grant_type" not in (data or {}): + # Device authorization endpoint + response.status = 200 + response.json = AsyncMock(return_value=device_response_data) + response.text = AsyncMock(return_value="") + else: + # Token endpoint + poll_count += 1 # ty: ignore[unresolved-reference] + if poll_count == 1: + response.status = 400 + response.json = AsyncMock(return_value={"error": "authorization_pending"}) + else: + response.status = 200 + response.json = AsyncMock(return_value=token_data) + + return _make_async_cm(response) + + mock_session = MagicMock() + mock_session.post = mock_post + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with ( + patch.object(config, "configuration", new_callable=AsyncMock, return_value=discovery), + patch("jumpstarter_cli_common.oidc.aiohttp.ClientSession", return_value=mock_session), + ): + result = await config.device_authorization_grant() + + assert result["access_token"] == "test-access-token" + assert result["refresh_token"] == "test-refresh-token" + assert poll_count == 2 + + @pytest.mark.asyncio + async def test_handles_slow_down_response(self) -> None: + config = Config(issuer="https://auth.example.com", client_id="test") + + discovery = { + "token_endpoint": "https://auth.example.com/token", + "device_authorization_endpoint": "https://auth.example.com/device", + } + + device_response_data = { + "device_code": "test-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://auth.example.com/device", + "interval": 0.01, + "expires_in": 300, + } + + token_data = {"access_token": "test-access-token", "token_type": "Bearer"} + + poll_count = 0 + + def mock_post(url, data=None, **kwargs): + nonlocal poll_count + response = MagicMock() + + if "device" in str(url) and "grant_type" not in (data or {}): + response.status = 200 + response.json = AsyncMock(return_value=device_response_data) + response.text = AsyncMock(return_value="") + else: + poll_count += 1 # ty: ignore[unresolved-reference] + if poll_count == 1: + response.status = 400 + response.json = AsyncMock(return_value={"error": "slow_down"}) + else: + response.status = 200 + response.json = AsyncMock(return_value=token_data) + + return _make_async_cm(response) + + mock_session = MagicMock() + mock_session.post = mock_post + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with ( + patch.object(config, "configuration", new_callable=AsyncMock, return_value=discovery), + patch("jumpstarter_cli_common.oidc.aiohttp.ClientSession", return_value=mock_session), + ): + result = await config.device_authorization_grant() + + assert result["access_token"] == "test-access-token" + + @pytest.mark.asyncio + async def test_raises_on_access_denied(self) -> None: + config = Config(issuer="https://auth.example.com", client_id="test") + + discovery = { + "token_endpoint": "https://auth.example.com/token", + "device_authorization_endpoint": "https://auth.example.com/device", + } + + device_response_data = { + "device_code": "test-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://auth.example.com/device", + "interval": 0.01, + "expires_in": 300, + } + + def mock_post(url, data=None, **kwargs): + response = MagicMock() + + if "device" in str(url) and "grant_type" not in (data or {}): + response.status = 200 + response.json = AsyncMock(return_value=device_response_data) + response.text = AsyncMock(return_value="") + else: + response.status = 400 + response.json = AsyncMock(return_value={"error": "access_denied"}) + + return _make_async_cm(response) + + mock_session = MagicMock() + mock_session.post = mock_post + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with ( + patch.object(config, "configuration", new_callable=AsyncMock, return_value=discovery), + patch("jumpstarter_cli_common.oidc.aiohttp.ClientSession", return_value=mock_session), + ): + with pytest.raises(click.ClickException, match="denied by the user"): + await config.device_authorization_grant() + + @pytest.mark.asyncio + async def test_raises_on_expired_token(self) -> None: + config = Config(issuer="https://auth.example.com", client_id="test") + + discovery = { + "token_endpoint": "https://auth.example.com/token", + "device_authorization_endpoint": "https://auth.example.com/device", + } + + device_response_data = { + "device_code": "test-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://auth.example.com/device", + "interval": 0.01, + "expires_in": 300, + } + + def mock_post(url, data=None, **kwargs): + response = MagicMock() + + if "device" in str(url) and "grant_type" not in (data or {}): + response.status = 200 + response.json = AsyncMock(return_value=device_response_data) + response.text = AsyncMock(return_value="") + else: + response.status = 400 + response.json = AsyncMock(return_value={"error": "expired_token"}) + + return _make_async_cm(response) + + mock_session = MagicMock() + mock_session.post = mock_post + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with ( + patch.object(config, "configuration", new_callable=AsyncMock, return_value=discovery), + patch("jumpstarter_cli_common.oidc.aiohttp.ClientSession", return_value=mock_session), + ): + with pytest.raises(click.ClickException, match="expired"): + await config.device_authorization_grant() + + +# --------------------------------------------------------------------------- +# Warning suppression tests (NS-REQ-4, NS-REQ-5) +# --------------------------------------------------------------------------- + + class TestAuthlibDeprecationWarningSuppressed: - """Importing oidc must not emit AuthlibDeprecationWarning.""" + """TS-NS-4: importing oidc must not emit AuthlibDeprecationWarning.""" def test_no_authlib_deprecation_warning_on_import(self) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") + # Force re-evaluation of the filter + import chain import importlib import jumpstarter_cli_common.oidc @@ -47,7 +309,7 @@ def test_no_authlib_deprecation_warning_on_import(self) -> None: class TestInsecureRequestWarningSuppressed: - """Config.client() with insecure_tls=True suppresses InsecureRequestWarning.""" + """TS-NS-5: Config.client() with insecure_tls=True suppresses InsecureRequestWarning.""" def test_urllib3_insecure_request_warning_suppressed(self) -> None: import urllib3.exceptions @@ -55,6 +317,8 @@ def test_urllib3_insecure_request_warning_suppressed(self) -> None: config = Config(issuer="https://auth.example.com", client_id="test", insecure_tls=True) config.client() + # After calling client() with insecure_tls=True, the urllib3 + # InsecureRequestWarning should be in the warning filters. matching_filters = [ f for f in warnings.filters diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/login.py b/python/packages/jumpstarter-cli/jumpstarter_cli/login.py index b1b0f240d..8ba54c596 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/login.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/login.py @@ -8,7 +8,7 @@ from jumpstarter_cli_common.blocking import blocking from jumpstarter_cli_common.config import opt_config from jumpstarter_cli_common.exceptions import handle_exceptions -from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_oidc +from jumpstarter_cli_common.oidc import Config, decode_jwt_issuer, opt_oidc, should_use_device_flow from jumpstarter_cli_common.opt import confirm_insecure_tls, opt_insecure_tls, opt_nointeractive from jumpstarter.common.exceptions import ReauthenticationFailed @@ -160,6 +160,7 @@ async def login( # noqa: C901 connector_id: str, callback_port: int | None, offline_access: bool, + device_flow: bool, unsafe, insecure_tls: bool, nointeractive: bool, @@ -341,6 +342,8 @@ def save_config() -> None: 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) + elif should_use_device_flow(device_flow): + tokens = await oidc.device_authorization_grant() else: tokens = await oidc.authorization_code_grant(callback_port=callback_port) @@ -352,8 +355,10 @@ def save_config() -> None: config.refresh_token = refresh_token save_config() - # Set the new client as the default if it's a client config - if config_kind in ("client", "client_config") and isinstance(config, ClientConfigV1Alpha1): + # Set the new client as the default if it's an alias-based client config. + # Path-based configs (--client-config ) aren't addressable by alias, + # so they can't be tracked as the user's "current client". + if config_kind == "client" and isinstance(config, ClientConfigV1Alpha1): user_config = UserConfigV1Alpha1.load_or_create() user_config.use_client(config.alias) click.echo(f"Set '{config.alias}' as the default client.") @@ -389,7 +394,10 @@ async def relogin_client(config: ClientConfigV1Alpha1): except Exception: pass - tokens = await oidc.authorization_code_grant() + if should_use_device_flow(device_flow_flag=False): + tokens = await oidc.device_authorization_grant() + else: + tokens = await oidc.authorization_code_grant() config.token = tokens["access_token"] refresh_token = tokens.get("refresh_token") if refresh_token is not None: diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/login_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/login_test.py index 090c27380..22a834b73 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/login_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/login_test.py @@ -279,3 +279,160 @@ async def authorization_code_grant(self, **kwargs): assert result.exit_code != 0 assert "TLS certificate validation failed" in result.output assert "Traceback" not in result.output + + +def test_login_uses_device_flow_when_flag_is_passed(monkeypatch, tmp_path) -> None: + """When --device-flow is passed, device_authorization_grant is called instead of authorization_code_grant.""" + auth_config = { + "grpcEndpoint": "grpc.example.com:443", + "namespace": "default", + "oidc": [{"issuer": "https://auth.example.com", "clientId": "test-client"}], + } + + async def fake_fetch_auth_config(*args, **kwargs): + return auth_config + + device_flow_called = False + auth_code_called = False + + class FakeOidcConfig: + def __init__(self, *args, **kwargs): + pass + + async def device_authorization_grant(self): + nonlocal device_flow_called + device_flow_called = True + return {"access_token": "test-token"} + + async def authorization_code_grant(self, **kwargs): + nonlocal auth_code_called + auth_code_called = True + return {"access_token": "test-token"} + + monkeypatch.setattr("jumpstarter_cli.login.fetch_auth_config", fake_fetch_auth_config) + monkeypatch.setattr("jumpstarter_cli.login.Config", FakeOidcConfig) + + runner = CliRunner() + result = runner.invoke( + jmp, + [ + "login", + "test-client@login.example.com", + "--client-config", + str(tmp_path / "nonexistent-client.yaml"), + "--nointeractive", + "--unsafe", + "--device-flow", + ], + ) + + assert result.exit_code == 0, result.output + assert device_flow_called is True + assert auth_code_called is False + + +def test_login_uses_device_flow_when_env_var_is_set(monkeypatch, tmp_path) -> None: + """When JMP_OIDC_DEVICE_FLOW=1, device_authorization_grant is called automatically.""" + auth_config = { + "grpcEndpoint": "grpc.example.com:443", + "namespace": "default", + "oidc": [{"issuer": "https://auth.example.com", "clientId": "test-client"}], + } + + async def fake_fetch_auth_config(*args, **kwargs): + return auth_config + + device_flow_called = False + auth_code_called = False + + class FakeOidcConfig: + def __init__(self, *args, **kwargs): + pass + + async def device_authorization_grant(self): + nonlocal device_flow_called + device_flow_called = True + return {"access_token": "test-token"} + + async def authorization_code_grant(self, **kwargs): + nonlocal auth_code_called + auth_code_called = True + return {"access_token": "test-token"} + + monkeypatch.setattr("jumpstarter_cli.login.fetch_auth_config", fake_fetch_auth_config) + monkeypatch.setattr("jumpstarter_cli.login.Config", FakeOidcConfig) + monkeypatch.setenv("JMP_OIDC_DEVICE_FLOW", "1") + + runner = CliRunner() + result = runner.invoke( + jmp, + [ + "login", + "test-client@login.example.com", + "--client-config", + str(tmp_path / "nonexistent-client.yaml"), + "--nointeractive", + "--unsafe", + ], + ) + + assert result.exit_code == 0, result.output + assert device_flow_called is True + assert auth_code_called is False + + +def test_login_uses_auth_code_flow_without_device_flow_signals(monkeypatch, tmp_path) -> None: + """Without --device-flow or JMP_OIDC_DEVICE_FLOW, authorization_code_grant is used (no regression).""" + auth_config = { + "grpcEndpoint": "grpc.example.com:443", + "namespace": "default", + "oidc": [{"issuer": "https://auth.example.com", "clientId": "test-client"}], + } + + async def fake_fetch_auth_config(*args, **kwargs): + return auth_config + + device_flow_called = False + auth_code_called = False + + class FakeOidcConfig: + def __init__(self, *args, **kwargs): + pass + + async def device_authorization_grant(self): + nonlocal device_flow_called + device_flow_called = True + return {"access_token": "test-token"} + + async def authorization_code_grant(self, **kwargs): + nonlocal auth_code_called + auth_code_called = True + return {"access_token": "test-token"} + + monkeypatch.setattr("jumpstarter_cli.login.fetch_auth_config", fake_fetch_auth_config) + monkeypatch.setattr("jumpstarter_cli.login.Config", FakeOidcConfig) + monkeypatch.delenv("JMP_OIDC_DEVICE_FLOW", raising=False) + + runner = CliRunner() + result = runner.invoke( + jmp, + [ + "login", + "test-client@login.example.com", + "--client-config", + str(tmp_path / "nonexistent-client.yaml"), + "--nointeractive", + "--unsafe", + ], + ) + + assert result.exit_code == 0, result.output + assert auth_code_called is True + assert device_flow_called is False + + +def test_env_py_contains_jmp_oidc_device_flow_constant() -> None: + """The JMP_OIDC_DEVICE_FLOW constant must exist in env.py.""" + from jumpstarter.config.env import JMP_OIDC_DEVICE_FLOW + + assert JMP_OIDC_DEVICE_FLOW == "JMP_OIDC_DEVICE_FLOW" diff --git a/python/packages/jumpstarter/jumpstarter/config/env.py b/python/packages/jumpstarter/jumpstarter/config/env.py index 40b7b5bec..27a27938b 100644 --- a/python/packages/jumpstarter/jumpstarter/config/env.py +++ b/python/packages/jumpstarter/jumpstarter/config/env.py @@ -14,6 +14,7 @@ JMP_DISABLE_COMPRESSION = "JMP_DISABLE_COMPRESSION" JMP_OIDC_CALLBACK_PORT = "JMP_OIDC_CALLBACK_PORT" +JMP_OIDC_DEVICE_FLOW = "JMP_OIDC_DEVICE_FLOW" JMP_GRPC_INSECURE = "JMP_GRPC_INSECURE" JUMPSTARTER_GRPC_INSECURE = "JUMPSTARTER_GRPC_INSECURE" JMP_GRPC_PASSPHRASE = "JMP_GRPC_PASSPHRASE"