Skip to content

Commit

Permalink
Merge branch 'main' into plan-system-tests
Browse files Browse the repository at this point in the history
  • Loading branch information
ZohebShaikh authored Jan 21, 2025
2 parents 148b1e0 + 5d8524e commit 29a5b79
Show file tree
Hide file tree
Showing 8 changed files with 132 additions and 14 deletions.
2 changes: 1 addition & 1 deletion dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ rich-toolkit==0.12.0
rpds-py==0.22.3
ruamel.yaml==0.18.6
ruamel.yaml.clib==0.2.12
ruff==0.8.2
ruff==0.9.1
scanspec==0.7.6
semver==3.0.2
setuptools-dso==2.11
Expand Down
67 changes: 67 additions & 0 deletions docs/how-to/authenticate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Authenticate to BlueAPI

## Introduction
BlueAPI provides a secure and efficient way to interact with its services. This guide walks you through the steps to log in and log out using BlueAPI with OpenID Connect (OIDC) authentication.

## Configuration

:::{seealso}
[Configure the Application](./configure-app.md)
:::

Here is an example configuration for authenticating to p46-blueapi:

```yaml
api:
host: "p46-blueapi.diamond.ac.uk"
port: 443
protocol: "https"

auth_token_path: "~/.cache/blueapi_cache" # Optional: Custom path to store the token
```
- **auth_token_path**: (Optional) Specify where to save the token. If omitted, the default is `~/.cache/blueapi_cache` or `$XDG_CACHE_HOME/blueapi_cache` if `XDG_CACHE_HOME` is set.

---

## Log In

1. Execute the login command:

```bash
$ blueapi -c config.yaml login
```

2. **Authenticate**:
- Follow the prompts from your OIDC provider to log in.
- Provide your credentials and complete any additional verification steps required by the provider.

3. **Success Message**:
Upon successful authentication, you see the following message:

```
Logged in and cached new token
```
---
## Log Out
To log out and securely remove the cached access token, follow these steps:
1. Execute the logout command:
```bash
$ blueapi logout
```

2. **Logout Process**:
- This command uses the OIDC flow to log you out from the OIDC provider.
- It also deletes the cached token from the specified `auth_token_path`.

3. **Success Message**:
If the token is successfully removed or if it does not exist, you see the message:

```
Logged out
```
6 changes: 6 additions & 0 deletions src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,9 @@ def scratch(obj: dict) -> None:
@check_connection
@click.pass_obj
def login(obj: dict) -> None:
"""
Authenticate with the blueapi using the OIDC (OpenID Connect) flow.
"""
config: ApplicationConfig = obj["config"]
try:
auth: SessionManager = SessionManager.from_cache(config.auth_token_path)
Expand All @@ -381,6 +384,9 @@ def login(obj: dict) -> None:
@main.command(name="logout")
@click.pass_obj
def logout(obj: dict) -> None:
"""
Logs out from the OIDC provider and removes the cached access token.
"""
config: ApplicationConfig = obj["config"]
try:
auth: SessionManager = SessionManager.from_cache(config.auth_token_path)
Expand Down
6 changes: 3 additions & 3 deletions src/blueapi/cli/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def fmt_dict(t: dict[str, Any] | Any, ind: int = 1) -> str:
if not isinstance(t, dict):
return f" {t}"
pre = " " * (ind * 4)
return NL + NL.join(f"{pre}{k}:{fmt_dict(v, ind+1)}" for k, v in t.items() if v)
return NL + NL.join(f"{pre}{k}:{fmt_dict(v, ind + 1)}" for k, v in t.items() if v)


class OutputFormat(str, enum.Enum):
Expand Down Expand Up @@ -126,14 +126,14 @@ def _describe_type(spec: dict[Any, Any], required: bool = False):
case None:
if all_of := spec.get("allOf"):
items = (_describe_type(f, False) for f in all_of)
disp += f'{" & ".join(items)}'
disp += f"{' & '.join(items)}"
elif any_of := spec.get("anyOf"):
items = (_describe_type(f, False) for f in any_of)

# Special case: Where the type is <something> | null,
# we should just print <something>
items = (item for item in items if item != "null" or len(any_of) != 2)
disp += f'{" | ".join(items)}'
disp += f"{' | '.join(items)}"
else:
disp += "Any"
case "array":
Expand Down
3 changes: 1 addition & 2 deletions src/blueapi/cli/scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ def ensure_repo(remote_url: str, local_directory: Path) -> None:
logging.info(f"Found {local_directory}")
else:
raise KeyError(
f"Unable to open {local_directory} as a git repository because "
"it is a file"
f"Unable to open {local_directory} as a git repository because it is a file"
)


Expand Down
15 changes: 15 additions & 0 deletions src/blueapi/service/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@


class CacheManager(ABC):
@abstractmethod
def can_access_cache(self) -> bool: ...
@abstractmethod
def save_cache(self, cache: Cache) -> None: ...
@abstractmethod
Expand Down Expand Up @@ -63,6 +65,18 @@ def _default_token_cache_path(self) -> Path:
cache_path = os.environ.get("XDG_CACHE_HOME", DEFAULT_CAHCE_DIR)
return Path(cache_path).expanduser() / "blueapi_cache"

def can_access_cache(self) -> bool:
assert self._token_path
try:
self._token_path.write_text("")
except IsADirectoryError:
print("Invalid path: a directory path was provided instead of a file path")
return False
except PermissionError:
print(f"Permission denied: Cannot write to {self._token_path.absolute()}")
return False
return True


class SessionManager:
def __init__(self, server_config: OIDCConfig, cache_manager: CacheManager) -> None:
Expand Down Expand Up @@ -179,6 +193,7 @@ def poll_for_token(
raise TimeoutError("Polling timed out")

def start_device_flow(self):
assert self._cache_manager.can_access_cache()
print("Logging in")
response: requests.Response = requests.post(
self._server_config.device_authorization_endpoint,
Expand Down
41 changes: 36 additions & 5 deletions tests/unit_tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import pytest
import responses
import yaml
from bluesky_stomp.messaging import StompClient
from click.testing import CliRunner
from pydantic import BaseModel, ValidationError
Expand All @@ -20,7 +21,7 @@
from blueapi.cli.cli import main
from blueapi.cli.format import OutputFormat, fmt_dict
from blueapi.client.rest import BlueskyRemoteControlError
from blueapi.config import ScratchConfig, ScratchRepository
from blueapi.config import ApplicationConfig, ScratchConfig, ScratchRepository
from blueapi.core.bluesky_types import DataEvent, Plan
from blueapi.service.model import (
DeviceModel,
Expand Down Expand Up @@ -310,7 +311,7 @@ def test_env_timeout(mock_sleep: Mock, runner: CliRunner):
assert responses.calls[0].request.url == "http://localhost:8000/environment"

# Remaining calls should all be GET
for call in responses.calls[1:]: # Skip the first DELETE request
for call in responses.calls[1:]: # Skip the first DELETE request # type: ignore
assert call.request.method == "GET"
assert call.request.url == "http://localhost:8000/environment"

Expand All @@ -329,9 +330,9 @@ def test_env_reload_server_side_error(runner: CliRunner):
)

result = runner.invoke(main, ["controller", "env", "-r"])
assert isinstance(
result.exception, BlueskyRemoteControlError
), "Expected a BlueskyRemoteError from cli runner"
assert isinstance(result.exception, BlueskyRemoteControlError), (
"Expected a BlueskyRemoteError from cli runner"
)
assert result.exception.args[0] == "Failed to tear down the environment"

# Check if the endpoints were hit as expected
Expand Down Expand Up @@ -745,3 +746,33 @@ def test_local_cache_cleared_on_logout_when_oidc_unavailable(
in result.output
)
assert not cached_valid_refresh.exists()


def test_wrapper_is_a_directory_error(
runner: CliRunner, mock_authn_server: responses.RequestsMock, tmp_path
):
config: ApplicationConfig = ApplicationConfig(auth_token_path=tmp_path)
config_path = tmp_path / "config.yaml"
with open(config_path, mode="w") as valid_auth_config_file:
valid_auth_config_file.write(yaml.dump(config.model_dump()))
result = runner.invoke(main, ["-c", config_path.as_posix(), "login"])
assert (
"Invalid path: a directory path was provided instead of a file path\n"
== result.stdout
)


def test_wrapper_permission_error(
runner: CliRunner, mock_authn_server: responses.RequestsMock, tmp_path
):
token_file: Path = tmp_path / "dir/token"
token_file.parent.mkdir()
# Change the dir permissions to read-only
(tmp_path / "dir").chmod(0o400)

config: ApplicationConfig = ApplicationConfig(auth_token_path=token_file)
config_path = tmp_path / "config.yaml"
with open(config_path, mode="w") as valid_auth_config_file:
valid_auth_config_file.write(yaml.dump(config.model_dump()))
result = runner.invoke(main, ["-c", config_path.as_posix(), "login"])
assert f"Permission denied: Cannot write to {token_file}\n" == result.stdout
6 changes: 3 additions & 3 deletions tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,9 @@ def test_config_yaml_parsed_complete(temp_yaml_config_file: dict):
del target_dict_json["stomp"]["auth"]["password"]
del config_data["stomp"]["auth"]["password"] # noqa: E501
# Assert that the remaining config data is identical
assert (
target_dict_json == config_data
), f"Expected config {config_data}, but got {target_dict_json}"
assert target_dict_json == config_data, (
f"Expected config {config_data}, but got {target_dict_json}"
)


def test_oauth_config_model_post_init(
Expand Down

0 comments on commit 29a5b79

Please sign in to comment.