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
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
7 changes: 6 additions & 1 deletion docs/reference/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ components:
description: Client ID
title: Client Id
type: string
logout_redirect_endpoint:
default: ''
description: The oidc endpoint required to logout
title: Logout Redirect Endpoint
type: string
well_known_url:
description: URL to fetch OIDC config from the provider
title: Well Known Url
Expand Down Expand Up @@ -350,7 +355,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
6 changes: 6 additions & 0 deletions helm/blueapi/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@
"description": "Client Audience(s)",
"title": "Client Audience",
"type": "string"
},
"logout_redirect_endpoint": {
"default": "",
"description": "The oidc endpoint required to logout",
"title": "Logout Redirect Endpoint",
"type": "string"
}
},
"required": [
Expand Down
6 changes: 6 additions & 0 deletions helm/blueapi/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,12 @@
"description": "Client ID",
"type": "string"
},
"logout_redirect_endpoint": {
"title": "Logout Redirect Endpoint",
"description": "The oidc endpoint required to logout",
"default": "",
"type": "string"
},
"well_known_url": {
"title": "Well Known Url",
"description": "URL to fetch OIDC config from the provider",
Expand Down
3 changes: 3 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,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 = Field(
description="The oidc endpoint required to logout", default=""
)

@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 not config.logout_redirect_endpoint:
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
46 changes: 44 additions & 2 deletions tests/unit_tests/service/test_rest_api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock, Mock, patch

import jwt
import pytest
from bluesky.protocols import Stoppable
from fastapi import status
from fastapi.testclient import TestClient
from httpx import Headers
from pydantic import BaseModel, ValidationError
from pydantic_core import InitErrorDetails
from super_state_machine.errors import TransitionError
Expand Down Expand Up @@ -62,11 +64,15 @@ def client(mock_runner: Mock) -> Iterator[TestClient]:

@pytest.fixture
def client_with_auth(
mock_runner: Mock, oidc_config: OIDCConfig
mock_runner: Mock, oidc_config: OIDCConfig, valid_token_with_jwt: dict[str, Any]
) -> Iterator[TestClient]:
with patch("blueapi.service.interface.worker"):
main.setup_runner(runner=mock_runner)
yield TestClient(main.get_app(ApplicationConfig(oidc=oidc_config)))
access_token = valid_token_with_jwt.get("access_token")
assert access_token is not None
client = TestClient(main.get_app(ApplicationConfig(oidc=oidc_config)))
client.headers = Headers(headers={"Authorization": f"Bearer {access_token}"})
yield client
main.teardown_runner()


Expand Down Expand Up @@ -720,3 +726,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_with_auth: TestClient,
):
oidc_config.logout_redirect_endpoint = "/oauth2/logout"
mock_runner.run.return_value = oidc_config
client_with_auth.follow_redirects = False
response = client_with_auth.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_with_auth: TestClient,
):
if has_oidc_config:
oidc_config.logout_redirect_endpoint = ""
mock_runner.run.return_value = oidc_config
else:
mock_runner.run.return_value = None

response = client_with_auth.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 @@ -312,6 +312,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": "",
},
"scratch": {
"root": "/tmp/scratch/blueapi",
Expand Down Expand Up @@ -358,6 +359,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": "",
},
"scratch": {
"root": "/tmp/scratch/blueapi",
Expand Down