Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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: 5 additions & 1 deletion docs/how-to/authenticate.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Authenticate to BlueAPI
> [!NOTE]
> If you are using `oauth2-proxy` to secure the Swagger UI documentation page, you can log out by visiting the `/logout` URL. For this to work correctly, ensure that the blueapi server is configured with
> `oidc.logout_redirect_endpoint` set to `/oauth2/sign_out`, which is required for `oauth2-proxy`.

# Authenticate to BlueAPI-Cli

## 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.
Expand Down
8 changes: 7 additions & 1 deletion docs/reference/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ components:
description: Client ID
title: Client Id
type: string
logout_redirect_endpoint:
anyOf:
- type: string
- type: 'null'
description: The oidc endpoint required to logout
title: Logout Redirect Endpoint
well_known_url:
description: URL to fetch OIDC config from the provider
title: Well Known Url
Expand Down Expand Up @@ -350,7 +356,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: BlueAPI Control
version: 1.0.2
version: 1.1.0
openapi: 3.1.0
paths:
/config/oidc:
Expand Down
3 changes: 3 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ class OIDCConfig(BlueapiBaseModel):
)
client_id: str = Field(description="Client ID")
client_audience: str = Field(description="Client Audience(s)", default="blueapi")
logout_redirect_endpoint: str | None = Field(
Comment thread
ZohebShaikh marked this conversation as resolved.
Outdated
description="The oidc endpoint required to logout", default=None
)
Comment thread
ZohebShaikh marked this conversation as resolved.

@cached_property
def _config_from_oidc_url(self) -> dict[str, Any]:
Expand Down
16 changes: 15 additions & 1 deletion src/blueapi/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
status,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2AuthorizationCodeBearer
from observability_utils.tracing import (
add_span_attributes,
Expand Down Expand Up @@ -56,7 +57,7 @@
from .runner import WorkerDispatcher

#: API version to publish in OpenAPI schema
REST_API_VERSION = "1.0.2"
REST_API_VERSION = "1.1.0"

LICENSE_INFO: dict[str, str] = {
"name": "Apache 2.0",
Expand Down Expand Up @@ -534,6 +535,19 @@ def health_probe() -> HealthProbeResponse:
return HealthProbeResponse(status=Health.OK)


@secure_router.get("/logout", include_in_schema=False)
def logout(runner: Annotated[WorkerDispatcher, Depends(_runner)]) -> Response:
"""Redirect to logout url"""
config = runner.run(interface.get_oidc_config)
if config is None or config.logout_redirect_endpoint is None:
raise HTTPException(status_code=status.HTTP_205_RESET_CONTENT)
return RedirectResponse(
status_code=status.HTTP_308_PERMANENT_REDIRECT,
url=config.logout_redirect_endpoint,
headers={"X-Auth-Request-Redirect": config.end_session_endpoint},
)


@start_as_current_span(TRACER, "config")
def start(config: ApplicationConfig):
import uvicorn
Expand Down
60 changes: 60 additions & 0 deletions tests/unit_tests/service/test_rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest
from bluesky.protocols import Stoppable
from fastapi import status
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from pydantic import BaseModel, ValidationError
from pydantic_core import InitErrorDetails
Expand Down Expand Up @@ -70,6 +71,29 @@ def client_with_auth(
main.teardown_runner()


@pytest.fixture
def client_authenticated(
mock_runner: Mock, oidc_config: OIDCConfig
) -> Iterator[TestClient]:
with patch("blueapi.service.interface.worker"):
main.setup_runner(runner=mock_runner)
app = main.get_app(ApplicationConfig(oidc=oidc_config))
dependant_dependencies = []
for route in app.routes:
if isinstance(route, APIRoute) and route.path == "/config/oidc":
dependant_dependencies = route.dependant.dependencies
break

for route in app.routes:
if isinstance(route, APIRoute):
route.dependencies = []
temp = dependant_dependencies
temp[0].path = route.path
route.dependant.dependencies = temp
yield TestClient(app)
main.teardown_runner()


@pytest.fixture
def rest_config_with_cors() -> RestConfig:
cors_config = CORSConfig(
Expand Down Expand Up @@ -720,3 +744,39 @@ def test_health_probe(client: TestClient):

assert response.status_code == status.HTTP_200_OK
assert response.json() == {"status": "ok"}


def test_logout(
mock_runner: Mock,
mock_authn_server,
oidc_config: OIDCConfig,
client_authenticated: TestClient,
):
oidc_config.logout_redirect_endpoint = "/oauth2/logout"
mock_runner.run.return_value = oidc_config
client_authenticated.follow_redirects = False
response = client_authenticated.get("/logout")
assert response.status_code == status.HTTP_308_PERMANENT_REDIRECT
assert (
response.headers.get("X-Auth-Request-Redirect")
== oidc_config.end_session_endpoint
)
assert response.headers.get("location") == oidc_config.logout_redirect_endpoint


@pytest.mark.parametrize("has_oidc_config", [True, False])
def test_logout_when_oidc_config_invalid(
has_oidc_config: bool,
mock_runner: Mock,
oidc_config: OIDCConfig,
mock_authn_server,
client_authenticated: TestClient,
):
if has_oidc_config:
oidc_config.logout_redirect_endpoint = None
mock_runner.run.return_value = oidc_config
else:
mock_runner.run.return_value = None

response = client_authenticated.get("/logout")
assert response.status_code == status.HTTP_205_RESET_CONTENT
2 changes: 2 additions & 0 deletions tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
"well_known_url": "https://auth.example.com/realms/sample/.well-known/openid-configuration",
"client_id": "blueapi-client",
"client_audience": "aud",
"logout_redirect_endpoint": None,
},
"scratch": {
"root": "/tmp/scratch/blueapi",
Expand Down Expand Up @@ -353,6 +354,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
"well_known_url": "https://auth.example.com/realms/sample/.well-known/openid-configuration",
"client_id": "blueapi-client",
"client_audience": "aud",
"logout_redirect_endpoint": None,
},
"scratch": {
"root": "/tmp/scratch/blueapi",
Expand Down