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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# 2.24.0 (unreleased)

* Commands:
* Review authentication flow, including interactively prompting for a token (\#924, \#929).
* Review authentication flow for standard `fractal` commands, including interactively prompting for a token (\#924, \#929).
* Introduce `fractal auth {check-token,set-token,clear-token}` commands (\#931).
* Support `FRACTAL_WEB` configuration variable (\#924, \#930).
* Include hint about where to find a valid token (\#930).
* Internal:
Expand Down
21 changes: 21 additions & 0 deletions src/fractal_client/cmd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
from fractal_client.auth._client import AuthClient
from fractal_client.interface import Interface

from ._auth import auth_check_token
from ._auth import auth_clear_token
from ._auth import auth_set_token
from ._dataset import delete_dataset
from ._dataset import get_dataset
from ._dataset import patch_dataset
Expand Down Expand Up @@ -477,3 +480,21 @@ def template(
raise NoCommandError(f"Command 'template {subcmd}' not found")

return iface


def auth(*, subcmd: str, **kwargs) -> Interface:
if subcmd == "check-token":
parameters = ["fractal_server", "token_path"]
function_kwargs = get_kwargs(parameters, kwargs)
iface = auth_check_token(**function_kwargs)
elif subcmd == "set-token":
parameters = ["token_path"]
function_kwargs = get_kwargs(parameters, kwargs)
iface = auth_set_token(**function_kwargs)
elif subcmd == "clear-token":
parameters = ["token_path"]
function_kwargs = get_kwargs(parameters, kwargs)
iface = auth_clear_token(**function_kwargs)
else:
raise NoCommandError(f"Command 'auth {subcmd}' not found")
return iface
70 changes: 70 additions & 0 deletions src/fractal_client/cmd/_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import sys
from pathlib import Path

from httpx2 import Client
from httpx2._models import Response

from fractal_client.auth._token_utils import _get_token_hint
from fractal_client.auth._token_utils import _is_token_valid
from fractal_client.config import settings
from fractal_client.interface import Interface


def auth_check_token(*, fractal_server: str, token_path: str | None) -> Interface:
path = Path(token_path or settings.default_token_path)
if not path.exists():
sys.exit(f"File not found at {path}.")
token = Path(path).read_text().strip()
try:
with Client() as client:
url = f"{fractal_server}/auth/current-user/"
res: Response = client.get(
url, headers={"Authorization": f"Bearer {token}"}
)
if res.status_code != 200:
raise ValueError(f"Server responded with {res}")
user = res.json()
user_email = user["email"]
return Interface(
retcode=0,
data=f"Valid token for {user_email} found at {path}.",
)
except Exception as e:
return Interface(
retcode=1,
data=f"Token verification failed. Original error: {str(e)}",
)


def auth_set_token(*, token_path: str | None) -> Interface:
path = Path(token_path or settings.default_token_path)
hint = _get_token_hint()
prompt_msg = f"Paste a valid token here{hint}, and it will be written to {path}: "
token = input(prompt_msg).strip()
if _is_token_valid(token):
path.parent.mkdir(exist_ok=True, parents=True)
path.write_text(token)
return Interface(
retcode=0,
data=f"Token written to {path}.",
)
else:
return Interface(
retcode=1,
data="The token provided is invalid or expired/expiring. Exit.",
)


def auth_clear_token(*, token_path: str | None) -> Interface:
path = Path(token_path or settings.default_token_path)
if path.exists():
path.unlink()
return Interface(
retcode=0,
data=f"Removed {path}.",
)
else:
return Interface(
retcode=1,
data=f"File not found at {path}, exit.",
)
4 changes: 4 additions & 0 deletions src/fractal_client/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ def handle(cli_args: list[str]) -> Interface:
try:
if args.cmd == "version":
interface = cmd_handler(fractal_server)
elif args.cmd == "auth":
kwargs = vars(args).copy()
kwargs.pop("fractal_server")
interface = cmd_handler(fractal_server=fractal_server, **kwargs)
else:
auth_info: AuthInfo = get_auth_info(args=args)
with AuthClient(
Expand Down
2 changes: 2 additions & 0 deletions src/fractal_client/parser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Zurich.
"""

from ._auth import add_auth_parser
from ._dataset import add_dataset_parser
from ._job import add_job_parser
from ._main import get_main_parser
Expand Down Expand Up @@ -41,3 +42,4 @@
add_resource_parser(subparsers_main)
add_profile_parser(subparsers_main)
add_template_parser(subparsers_main)
add_auth_parser(subparsers_main)
30 changes: 30 additions & 0 deletions src/fractal_client/parser/_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
def add_auth_parser(subparsers):
auth_parser = subparsers.add_parser(
"auth",
description="Authentication commands.",
allow_abbrev=False,
)
auth_subparsers = auth_parser.add_subparsers(
title="Valid sub-commands", dest="subcmd", required=True
)

check_token_parser = auth_subparsers.add_parser( # noqa: F841
"check-token",
description="Check whether a valid token is available.",
allow_abbrev=False,
)

set_token_parser = auth_subparsers.add_parser( # noqa: F841
"set-token",
description=(
"Write a valid token to disk (using the default path or "
"the user-provided `--token-path` one)."
),
allow_abbrev=False,
)

clear_token_parser = auth_subparsers.add_parser( # noqa: F841
"clear-token",
description="Remove the file storing the token, if valid.",
allow_abbrev=False,
)
3 changes: 2 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def override_settings(monkeypatch, tmp_path):
import fractal_client.config

def _override_settings(
FRACTAL_CACHE_PATH=str(tmp_path),
FRACTAL_CACHE_PATH=str(tmp_path / "cache"),
FRACTAL_USER=None,
FRACTAL_PASSWORD=None,
FRACTAL_SERVER=None,
Expand All @@ -113,6 +113,7 @@ def _override_settings(
"FRACTAL_CACHE_PATH",
FRACTAL_CACHE_PATH,
)
Path(FRACTAL_CACHE_PATH).mkdir(exist_ok=True, parents=True)
monkeypatch.setattr(
fractal_client.config.settings,
"FRACTAL_USER",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_auth_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import io
from datetime import datetime
from datetime import timedelta
from pathlib import Path

import jwt
import pytest

from fractal_client.auth._args import AuthInfo
from fractal_client.auth._client import AuthClient
from fractal_client.config import settings


@pytest.fixture(scope="function")
def valid_token(tester) -> str:
from fractal_client.auth._token_utils import _is_token_valid

with AuthClient(
fractal_server="http://localhost:8765",
auth_info=AuthInfo(
user=tester["email"],
password=tester["password"],
token_path=None,
),
) as client:
token = client.token
assert _is_token_valid(token)
return token


def test_auth_commands(
invoke,
monkeypatch,
tmp_path: Path,
valid_token: str,
override_settings,
tester: dict,
):
override_settings()
custom_token_path = (tmp_path / "custom_token.txt").as_posix()

# Fully invalid token
invalid_token = "not-a-token"

# Partially valid token: it is a valid and not-expiring JWT token, but its signature
# is based on a key different from the fractal-server one.
partially_valid_token = jwt.encode(
payload={"exp": (datetime.now() + timedelta(days=1)).timestamp()},
key="some-long-jwt-secret-key-which-should-be-long",
algorithm="HS256",
)

for option, token_path in zip(
(f"--token-path {custom_token_path}", ""),
(custom_token_path, settings.default_token_path),
):
assert not Path(token_path).exists()

with pytest.raises(SystemExit, match="not found"):
invoke(f"{option} auth check-token")

res = invoke(f"{option} auth clear-token")
assert res.retcode == 1
assert "not found" in res.data

monkeypatch.setattr("sys.stdin", io.StringIO(invalid_token))
res = invoke(f"{option} auth set-token")
assert res.retcode == 1
assert "invalid" in res.data
assert not Path(token_path).exists()

monkeypatch.setattr("sys.stdin", io.StringIO(valid_token))
res = invoke(f"{option} auth set-token")
assert res.retcode == 0
assert "Token written" in res.data
assert Path(token_path).exists()

res = invoke(f"{option} auth check-token")
assert f"Valid token for {tester['email']}" in res.data
assert res.retcode == 0

res = invoke(f"{option} auth clear-token")
assert res.retcode == 0
assert "Removed" in res.data

monkeypatch.setattr("sys.stdin", io.StringIO(partially_valid_token))
res = invoke(f"{option} auth set-token")
assert res.retcode == 0
assert "Token written" in res.data
assert Path(token_path).exists()

res = invoke(f"{option} auth check-token")
assert res.retcode == 1
assert "Token verification failed." in res.data
3 changes: 3 additions & 0 deletions tests/test_invalid_commands.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest

from fractal_client.cmd import NoCommandError
from fractal_client.cmd import auth
from fractal_client.cmd import dataset
from fractal_client.cmd import group
from fractal_client.cmd import job
Expand All @@ -27,6 +28,7 @@ def test_invalid_commands(invoke):
"resource",
"profile",
"template",
"auth",
]:
with pytest.raises(SystemExit):
invoke(f"{command}{arg}")
Expand All @@ -44,6 +46,7 @@ def test_unit_invalid_subcommand():
resource,
profile,
template,
auth,
]:
with pytest.raises(NoCommandError):
_function(client=None, subcmd="invalid")
Loading