Skip to content

Commit 2d61c5f

Browse files
authored
feat: Add logout redirect (#1166)
Currently we can logout by going to this https://p46-blueapi.diamond.ac.uk/oauth2/sign_out?rd=https%3A%2F%2Fauthn.diamond.ac.uk%2Frealms%2Fmaster%2Fprotocol%2Fopenid-connect%2Flogout. It will be much simpler to logout by going to https://p46-blueapi.diamond.ac.uk/logout. This is when we have oauth2-proxy enabled for blueapi docs page
1 parent 858e92d commit 2d61c5f

8 files changed

Lines changed: 87 additions & 5 deletions

File tree

docs/how-to/authenticate.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
# Authenticate to BlueAPI
1+
> [!NOTE]
2+
> 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
3+
> `oidc.logout_redirect_endpoint` set to `/oauth2/sign_out`, which is required for `oauth2-proxy`.
4+
5+
# Authenticate to BlueAPI-Cli
26

37
## Introduction
48
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.

docs/reference/openapi.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ components:
9292
description: Client ID
9393
title: Client Id
9494
type: string
95+
logout_redirect_endpoint:
96+
default: ''
97+
description: The oidc endpoint required to logout
98+
title: Logout Redirect Endpoint
99+
type: string
95100
well_known_url:
96101
description: URL to fetch OIDC config from the provider
97102
title: Well Known Url
@@ -350,7 +355,7 @@ info:
350355
name: Apache 2.0
351356
url: https://www.apache.org/licenses/LICENSE-2.0.html
352357
title: BlueAPI Control
353-
version: 1.0.2
358+
version: 1.1.0
354359
openapi: 3.1.0
355360
paths:
356361
/config/oidc:

helm/blueapi/config_schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@
196196
"description": "Client Audience(s)",
197197
"title": "Client Audience",
198198
"type": "string"
199+
},
200+
"logout_redirect_endpoint": {
201+
"default": "",
202+
"description": "The oidc endpoint required to logout",
203+
"title": "Logout Redirect Endpoint",
204+
"type": "string"
199205
}
200206
},
201207
"required": [

helm/blueapi/values.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,12 @@
676676
"description": "Client ID",
677677
"type": "string"
678678
},
679+
"logout_redirect_endpoint": {
680+
"title": "Logout Redirect Endpoint",
681+
"description": "The oidc endpoint required to logout",
682+
"default": "",
683+
"type": "string"
684+
},
679685
"well_known_url": {
680686
"title": "Well Known Url",
681687
"description": "URL to fetch OIDC config from the provider",

src/blueapi/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ class OIDCConfig(BlueapiBaseModel):
166166
)
167167
client_id: str = Field(description="Client ID")
168168
client_audience: str = Field(description="Client Audience(s)", default="blueapi")
169+
logout_redirect_endpoint: str = Field(
170+
description="The oidc endpoint required to logout", default=""
171+
)
169172

170173
@cached_property
171174
def _config_from_oidc_url(self) -> dict[str, Any]:

src/blueapi/service/main.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
status,
1818
)
1919
from fastapi.middleware.cors import CORSMiddleware
20+
from fastapi.responses import RedirectResponse
2021
from fastapi.security import OAuth2AuthorizationCodeBearer
2122
from observability_utils.tracing import (
2223
add_span_attributes,
@@ -56,7 +57,7 @@
5657
from .runner import WorkerDispatcher
5758

5859
#: API version to publish in OpenAPI schema
59-
REST_API_VERSION = "1.0.2"
60+
REST_API_VERSION = "1.1.0"
6061

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

536537

538+
@secure_router.get("/logout", include_in_schema=False)
539+
def logout(runner: Annotated[WorkerDispatcher, Depends(_runner)]) -> Response:
540+
"""Redirect to logout url"""
541+
config = runner.run(interface.get_oidc_config)
542+
if config is None or not config.logout_redirect_endpoint:
543+
raise HTTPException(status_code=status.HTTP_205_RESET_CONTENT)
544+
return RedirectResponse(
545+
status_code=status.HTTP_308_PERMANENT_REDIRECT,
546+
url=config.logout_redirect_endpoint,
547+
headers={"X-Auth-Request-Redirect": config.end_session_endpoint},
548+
)
549+
550+
537551
@start_as_current_span(TRACER, "config")
538552
def start(config: ApplicationConfig):
539553
import uvicorn

tests/unit_tests/service/test_rest_api.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import uuid
22
from collections.abc import Iterator
33
from dataclasses import dataclass
4+
from typing import Any
45
from unittest.mock import MagicMock, Mock, patch
56

67
import jwt
78
import pytest
89
from bluesky.protocols import Stoppable
910
from fastapi import status
1011
from fastapi.testclient import TestClient
12+
from httpx import Headers
1113
from pydantic import BaseModel, ValidationError
1214
from pydantic_core import InitErrorDetails
1315
from super_state_machine.errors import TransitionError
@@ -62,11 +64,15 @@ def client(mock_runner: Mock) -> Iterator[TestClient]:
6264

6365
@pytest.fixture
6466
def client_with_auth(
65-
mock_runner: Mock, oidc_config: OIDCConfig
67+
mock_runner: Mock, oidc_config: OIDCConfig, valid_token_with_jwt: dict[str, Any]
6668
) -> Iterator[TestClient]:
6769
with patch("blueapi.service.interface.worker"):
6870
main.setup_runner(runner=mock_runner)
69-
yield TestClient(main.get_app(ApplicationConfig(oidc=oidc_config)))
71+
access_token = valid_token_with_jwt.get("access_token")
72+
assert access_token is not None
73+
client = TestClient(main.get_app(ApplicationConfig(oidc=oidc_config)))
74+
client.headers = Headers(headers={"Authorization": f"Bearer {access_token}"})
75+
yield client
7076
main.teardown_runner()
7177

7278

@@ -720,3 +726,39 @@ def test_health_probe(client: TestClient):
720726

721727
assert response.status_code == status.HTTP_200_OK
722728
assert response.json() == {"status": "ok"}
729+
730+
731+
def test_logout(
732+
mock_runner: Mock,
733+
mock_authn_server,
734+
oidc_config: OIDCConfig,
735+
client_with_auth: TestClient,
736+
):
737+
oidc_config.logout_redirect_endpoint = "/oauth2/logout"
738+
mock_runner.run.return_value = oidc_config
739+
client_with_auth.follow_redirects = False
740+
response = client_with_auth.get("/logout")
741+
assert response.status_code == status.HTTP_308_PERMANENT_REDIRECT
742+
assert (
743+
response.headers.get("X-Auth-Request-Redirect")
744+
== oidc_config.end_session_endpoint
745+
)
746+
assert response.headers.get("location") == oidc_config.logout_redirect_endpoint
747+
748+
749+
@pytest.mark.parametrize("has_oidc_config", [True, False])
750+
def test_logout_when_oidc_config_invalid(
751+
has_oidc_config: bool,
752+
mock_runner: Mock,
753+
oidc_config: OIDCConfig,
754+
mock_authn_server,
755+
client_with_auth: TestClient,
756+
):
757+
if has_oidc_config:
758+
oidc_config.logout_redirect_endpoint = ""
759+
mock_runner.run.return_value = oidc_config
760+
else:
761+
mock_runner.run.return_value = None
762+
763+
response = client_with_auth.get("/logout")
764+
assert response.status_code == status.HTTP_205_RESET_CONTENT

tests/unit_tests/test_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
312312
"well_known_url": "https://auth.example.com/realms/sample/.well-known/openid-configuration",
313313
"client_id": "blueapi-client",
314314
"client_audience": "aud",
315+
"logout_redirect_endpoint": "",
315316
},
316317
"scratch": {
317318
"root": "/tmp/scratch/blueapi",
@@ -358,6 +359,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
358359
"well_known_url": "https://auth.example.com/realms/sample/.well-known/openid-configuration",
359360
"client_id": "blueapi-client",
360361
"client_audience": "aud",
362+
"logout_redirect_endpoint": "",
361363
},
362364
"scratch": {
363365
"root": "/tmp/scratch/blueapi",

0 commit comments

Comments
 (0)