Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions packages/reflex-hosting-cli/README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
# reflex-hosting-cli
Hosting CLI for Reflex.

If an access token is expired or rejected, run `reflex login` to authenticate
again. If you supply a token explicitly, replace it with a valid token or remove
the override before using the saved login: unset `REFLEX_ACCESS_TOKEN` or omit
`--token`. Logging in does not replace an environment variable or command-line
token. Noninteractive commands exit on rejection without opening a browser.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Show `reflex login` guidance when initial authentication rejects an access token, including instructions to replace or remove an explicit `REFLEX_ACCESS_TOKEN` or `--token` override.
When `reflex cloud whoami` cannot validate a token due to a temporary service or connection failure, suggest retrying instead of replacing credentials.
18 changes: 15 additions & 3 deletions packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,8 @@ def get_existing_access_token_with_source() -> tuple[str, TokenSource]:
return "", TokenSource.NONE


def rejected_token_message(source: TokenSource, err: TokenValidationError) -> str:
"""Describe a token the control plane would not validate.
def rejected_token_message(source: TokenSource, err: TokenAccessDeniedError) -> str:
"""Describe a rejected token and how to replace it for its source.

Args:
source: Where the token was loaded from.
Expand All @@ -475,9 +475,21 @@ def rejected_token_message(source: TokenSource, err: TokenValidationError) -> st
Returns:
The message to report.
"""
if source is TokenSource.ENVIRONMENT:
recovery = (
"Replace REFLEX_ACCESS_TOKEN with a valid token, or unset it and run "
"`reflex login` to authenticate."
)
elif source is TokenSource.OPTION:
recovery = (
"Replace the --token value with a valid token, or omit --token and run "
"`reflex login` to authenticate."
)
else:
recovery = "Run `reflex login` to authenticate."
Comment thread
masenf marked this conversation as resolved.
return (
f"The access token from the {source.value} was rejected: {err} "
f"(auth request id: {err.request_id})"
f"(auth request id: {err.request_id}). {recovery}"
Comment thread
masenf marked this conversation as resolved.
)


Expand Down
10 changes: 8 additions & 2 deletions packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from reflex_cli import constants
from reflex_cli.utils import console, log
from reflex_cli.utils.exceptions import TokenValidationError
from reflex_cli.utils.exceptions import TokenAccessDeniedError, TokenValidationError
from reflex_cli.utils.output import json_option, print_json

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -106,9 +106,15 @@ def whoami_command(token: str | None, loglevel: str, as_json: bool):

try:
validated_info = hosting.validate_token(access_token)
except TokenValidationError as err:
except TokenAccessDeniedError as err:
logger.error(hosting.rejected_token_message(source, err))
raise click.exceptions.Exit(1) from err
except TokenValidationError as err:
logger.error(
f"Unable to validate the access token from the {source.value}: "
f"{err} (auth request id: {err.request_id}). Please try again later."
Comment thread
masenf marked this conversation as resolved.
Outdated
)
raise click.exceptions.Exit(1) from err

identity = {
field: validated_info[field]
Expand Down
6 changes: 4 additions & 2 deletions tests/units/reflex_cli/utils/test_hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,9 +529,11 @@ def test_rejected_token_in_non_interactive_mode_does_not_prompt(
assert exc_info.value.exit_code == 1
browser.assert_not_called()
errors = [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR]
assert errors == [
assert len(errors) == 1
assert errors[0].startswith(
f"The access token from the {source.value} was rejected: access denied (auth request id: req-1)"
]
)
assert "reflex login" in errors[0]


def test_rejected_config_token_in_non_interactive_mode_is_removed(
Expand Down
120 changes: 119 additions & 1 deletion tests/units/reflex_cli/v2/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from reflex_cli.v2.auth import token_fingerprint
from reflex_cli.v2.deployments import hosting_cli

from .utils import as_click_command
from .utils import api_error, as_click_command, fake_client

hosting_cli = as_click_command(hosting_cli)

Expand Down Expand Up @@ -50,6 +50,70 @@ def test_token_fingerprint_is_stable_and_hides_the_token():
assert token_fingerprint("") == ""


@pytest.mark.parametrize(
("source", "guidance"),
[
(hosting.TokenSource.ENVIRONMENT, "Replace REFLEX_ACCESS_TOKEN"),
(hosting.TokenSource.OPTION, "Replace the --token value"),
(hosting.TokenSource.CONFIG, "Run `reflex login`"),
],
)
@pytest.mark.parametrize("status_code", [401, 403])
def test_initial_token_rejection_has_recovery_guidance(
mocker: MockFixture,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
source: hosting.TokenSource,
guidance: str,
status_code: int,
):
"""Initial auth rejection explains recovery without prompting or leaking tokens.

Args:
mocker: The pytest mock fixture.
monkeypatch: The pytest monkeypatch fixture.
caplog: The pytest log capture fixture.
source: Where the rejected token comes from.
guidance: The expected source-specific recovery instruction.
status_code: The authentication response status.
"""
stored_token = (
"expired-token" if source is hosting.TokenSource.CONFIG else "saved-token"
)
config = json.dumps({"access_token": stored_token})
constants.Hosting.HOSTING_JSON.write_text(config)
monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False)
args = ["apps", "list", "--json", "--no-interactive"]
if source is hosting.TokenSource.ENVIRONMENT:
monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "expired-token")
elif source is hosting.TokenSource.OPTION:
args.extend(["--token", "expired-token"])
client = fake_client()
error = api_error(status_code, "Token has expired")
client.api.auth.me.side_effect = error
mocker.patch("reflex_cli.utils.hosting.new_client", return_value=client.api)
browser = mocker.patch("reflex_cli.utils.hosting._authenticate_on_browser")

result = runner.invoke(hosting_cli, args)

assert result.exit_code == 1
assert result.stdout == ""
browser.assert_not_called()
client.api.close.assert_called_once()
errors = _messages(caplog, logging.ERROR)
assert len(errors) == 1
assert source.value in errors[0]
assert error.request_id in errors[0]
assert guidance in errors[0]
assert "reflex login" in errors[0]
assert "expired-token" not in errors[0]
assert "saved-token" not in errors[0]
if source is hosting.TokenSource.CONFIG:
assert hosting.stored_access_token() == ""
else:
assert constants.Hosting.HOSTING_JSON.read_text() == config


def test_whoami_reports_identity_and_token_source(mocker: MockFixture):
mocker.patch(
"reflex_cli.utils.hosting.get_existing_access_token_with_source",
Expand Down Expand Up @@ -186,6 +250,60 @@ def test_whoami_surfaces_the_auth_request_id(
errors = _messages(caplog, logging.ERROR)
assert any("req-123" in message for message in errors)
assert any("REFLEX_ACCESS_TOKEN" in message for message in errors)
assert any("reflex login" in message for message in errors)


@pytest.mark.parametrize("failure", ["server error", "request failed", "timeout"])
@pytest.mark.parametrize(
"source",
[
hosting.TokenSource.ENVIRONMENT,
hosting.TokenSource.OPTION,
hosting.TokenSource.CONFIG,
],
)
def test_whoami_temporary_validation_failure_suggests_retry(
mocker: MockFixture,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
source: hosting.TokenSource,
failure: str,
):
"""Temporary validation failures suggest retrying without changing credentials.

Args:
mocker: The pytest mock fixture.
monkeypatch: The pytest monkeypatch fixture.
caplog: The pytest log capture fixture.
source: Where the token comes from.
failure: The temporary validation error message.
"""
config = json.dumps({"access_token": "saved-token"})
constants.Hosting.HOSTING_JSON.write_text(config)
monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False)
args = ["whoami", "--json"]
if source is hosting.TokenSource.ENVIRONMENT:
monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "explicit-token")
elif source is hosting.TokenSource.OPTION:
args.extend(["--token", "explicit-token"])
mocker.patch(
"reflex_cli.utils.hosting.validate_token",
side_effect=TokenValidationError(failure, request_id="req-temporary"),
)
browser = mocker.patch("reflex_cli.utils.hosting._authenticate_on_browser")

result = runner.invoke(hosting_cli, args)

assert result.exit_code == 1
assert result.stdout == ""
assert constants.Hosting.HOSTING_JSON.read_text() == config
browser.assert_not_called()
assert _messages(caplog, logging.ERROR) == [
(
f"Unable to validate the access token from the {source.value}: "
f"{failure} (auth request id: req-temporary). Please try again later."
)
]


def test_token_print_writes_the_raw_token_to_stdout(mocker: MockFixture):
Expand Down
Loading