Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4c08d7e
First attempt at adding OIDC workflow
davidpcls Feb 18, 2026
5b24d4b
Removing some unnecessary changes
davidpcls Feb 18, 2026
9bf0417
Refactor for improved readability
davidpcls Feb 18, 2026
4d2973d
Moving common code to base
davidpcls Feb 18, 2026
4480c85
Adding in new unit tests and refactoring existing
davidpcls Feb 19, 2026
20fa385
Adding back some changes I made
davidpcls Feb 19, 2026
a8672cd
Adding uv support and docs on how to use it
davidpcls Feb 20, 2026
f191969
Fixes from the CI pipeline
davidpcls Feb 20, 2026
d16060e
Adding run-names to CI workflows
davidpcls Feb 20, 2026
15bec65
Fixing failing comms test
davidpcls Feb 23, 2026
b3f2172
Removing changes in readme, fixing test
davidpcls Feb 23, 2026
ed57d78
Adding parallelized unit testing
davidpcls Feb 23, 2026
ec542e3
Fixing file endings
davidpcls Feb 23, 2026
de6583b
Merge pull request #2 from davidpcls/adding_unit_tests
davidpcls Feb 23, 2026
aa3574e
ENH Fixing unit tests, updated runner, udpated doc
davidpcls Feb 24, 2026
0d36803
Merge branch 'adding_unit_tests' into adding_oidc_logins_to_api
davidpcls Feb 24, 2026
64a1e6e
Updating pyproject for Pixi/uv
davidpcls Mar 13, 2026
93fb631
Adding summary output to script
davidpcls Mar 13, 2026
3625a23
Merge pull request #3 from davidpcls/adding_oidc_logins_to_api
davidpcls Mar 13, 2026
b8d8696
Fixing asserts so they are more reliable
davidpcls Mar 16, 2026
179f745
Merge pull request #5 from davidpcls/unit_test_hardening
davidpcls Mar 16, 2026
53ade6d
Merge remote-tracking branch 'upstream/main'
davidpcls Jun 25, 2026
5900010
Removed obsolete test file
davidpcls Jun 25, 2026
e2da72f
Merge pull request #8 from davidpcls/main_merged
davidpcls Jun 25, 2026
ce564c4
Removing some unnecesary changes
davidpcls Aug 4, 2026
0d79823
Adding in requirements from bluesky-httpserver
davidpcls Aug 4, 2026
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
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.git
.github
.venv
.pytest_cache
__pycache__
*.pyc
*.pyo
*.pyd
build
dist
docs/_build
.mypy_cache
.coverage
coverage.xml
.docker-test-artifacts
1 change: 1 addition & 0 deletions .github/workflows/black.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Check Code Style - BLACK
run-name: Black Check • ${{ github.ref_name }} • @${{ github.actor }}

on: [push, pull_request]

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Documentation
run-name: Build Documentation • ${{ github.ref_name }} • @${{ github.actor }}

on: [push, pull_request]

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/docs_publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Publish Docs
run-name: Publish Docs • ${{ github.ref_name }} • @${{ github.actor }}

on:
push:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/flake8.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Check Code Style - FLAKE8
run-name: Flake8 Check • ${{ github.ref_name }} • @${{ github.actor }}

on: [push, pull_request]

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/isort.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Check Code Style - ISORT
run-name: isort Check • ${{ github.ref_name }} • @${{ github.actor }}

on: [push, pull_request]

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: pre-commit
run-name: Pre-commit Hooks • ${{ github.ref_name }} • @${{ github.actor }}

on:
push:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries

name: Upload Python Package
run-name: Publish Python Package • ${{ github.ref_name }} • @${{ github.actor }}

on:
release:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Unit Tests
run-name: Unit Tests • ${{ github.ref_name }} • @${{ github.actor }}

on: [push, pull_request]

Expand Down
87 changes: 85 additions & 2 deletions bluesky_queueserver_api/comm_async.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import asyncio
import webbrowser

import httpx
from bluesky_queueserver import ZMQCommSendAsync

Expand Down Expand Up @@ -174,10 +177,90 @@ async def send_request(
async def login(self, username=None, *, password=None, provider=None):
# Docstring is maintained separately
endpoint, data = self._prepare_login(username=username, password=password, provider=provider)
response = await self.send_request(method=("POST", endpoint), data=data, timeout=self._timeout_login)
response = self._process_login_response(response=response)

if self._is_external_auth(endpoint):
response = await self._oidc_device_code_login(endpoint=endpoint)
else:
response = await self._password_login(endpoint=endpoint, data=data)

return response

async def _password_login(self, endpoint, data):
"""Perform standard password-based login."""
response = await self.send_request(
method=("POST", endpoint), data=data, timeout=self._timeout_login, auto_refresh_session=False
)
return self._process_login_response(response=response)

async def _oidc_device_code_login(self, endpoint):
"""
Perform OIDC login using the device code flow.
Opens a browser for user authentication and polls for completion.
"""
device_params = await self._initiate_device_code_flow(endpoint)

self._oidc_prompt_user_for_auth(device_params)
webbrowser.open(device_params["authorization_uri"])

token_endpoint = endpoint.replace("/authorize", "/token")
return await self._poll_for_token(
token_endpoint=token_endpoint,
device_code=device_params["device_code"],
interval=device_params["interval"],
expires_in=device_params["expires_in"],
)

async def _initiate_device_code_flow(self, endpoint):
"""Initiate OIDC device code flow and return device parameters."""
device_response = await self.send_request(
method=("POST", endpoint), timeout=self._timeout_login, auto_refresh_session=False
)
return self._oicd_handle_initial_response(device_response)

async def _poll_for_token(self, token_endpoint, device_code, interval, expires_in):
"""Poll the token endpoint until authentication completes or times out."""

elapsed = 0

while elapsed < expires_in:
await asyncio.sleep(interval)
elapsed += interval

result, new_interval = await self._attempt_token_request(token_endpoint, device_code)
if result is not None:
return result
if new_interval is not None:
interval = new_interval

raise self.RequestTimeoutError(
"OIDC authentication timed out waiting for user authorization",
request={"method": "OIDC device code login", "endpoint": token_endpoint},
)

async def _attempt_token_request(self, token_endpoint, device_code):
"""
Attempt a single token request.
Returns (response, None) on success, (None, new_interval) to continue polling,
or raises an exception on error.
"""
try:
token_response = await self.send_request(
method=("POST", token_endpoint),
params={"device_code": device_code},
timeout=self._timeout_login,
auto_refresh_session=False,
)

if token_response.get("access_token"):
return self._process_login_response(response=token_response), None

return self._oidc_handle_token_polling_response(token_response)

except self.HTTPClientError as ex:
if "authorization_pending" in str(ex).lower():
return None, None
raise

async def session_refresh(self, *, refresh_token=None):
# Docstring is maintained separately
refresh_token = self._prepare_refresh_session(refresh_token=refresh_token)
Expand Down
82 changes: 66 additions & 16 deletions bluesky_queueserver_api/comm_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ def _prepare_headers(self, *, token=None, api_key=None):
``token`` or ``api_key`` passed as parameters override the default security keys set in the class.
"""
if (token is not None) and (api_key is not None):
raise self._RequestParameterError("The request contains both token and API key.")
raise self.RequestParameterError("The request contains both token and API key.")

auth_method = self.AuthorizationMethods.NONE
key_in_params = False
Expand Down Expand Up @@ -509,7 +509,25 @@ def set_authorization_key(self, *, api_key=None, token=None, refresh_token=None)
self._auth_key = None

def _prepare_login(self, *, username, password, provider):
# Interactively ask for username and password if they were not passed as parameters
endpoint = self._prepare_login_endpoint(provider)
if self._is_external_auth(endpoint):
data = {}
else:
data = self._prepare_login_data(username=username, password=password)
return endpoint, data

def _prepare_login_endpoint(self, provider):
provider = self._preprocess_endpoint_name(provider, msg="Authentication provider path")

selected_provider = provider or self._http_auth_provider
if not selected_provider:
raise self.RequestParameterError(
"Authentication provider is not specified: set default authentication provider "
"or pass the provider endpoint as a parameter"
)
return f"/api/auth/provider{selected_provider}"

def _prepare_login_data(self, username, password):
if username is None:
username = input("Username: ")
if password is None:
Expand All @@ -525,20 +543,7 @@ def _prepare_login(self, *, username, password, provider):
password = password.strip()
if not password:
raise self.RequestParameterError("'password' is an empty string")

provider = self._preprocess_endpoint_name(provider, msg="Authentication provider path")

selected_provider = provider or self._http_auth_provider
if not selected_provider:
raise self.RequestParameterError(
"Authentication provider is not specified: set default authentication provider "
"or pass the provider endpoint as a parameter"
)

data = {"username": username, "password": password}

endpoint = f"/api/auth/provider{selected_provider}"
return endpoint, data
return {"username": username, "password": password}

def _process_login_response(self, response):
"""
Expand All @@ -550,6 +555,51 @@ def _process_login_response(self, response):
self.set_authorization_key(token=access_token, refresh_token=refresh_token)
return response

def _is_external_auth(self, endpoint):
return "authorize" in endpoint

def _oidc_prompt_user_for_auth(self, device_params):
"""Display authentication instructions to the user."""
print(f"Opening browser for authentication: {device_params['authorization_uri']}")
if device_params["user_code"]:
print(f"Enter this code when prompted: {device_params['user_code']} (Do not enter the dash)")

def _oicd_handle_initial_response(self, device_response):
authorization_uri = device_response.get("authorization_uri") or device_response.get("verification_uri")
device_code = device_response.get("device_code")

if not all([authorization_uri, device_code]):
raise self.RequestParameterError(
"OIDC device code flow response missing required fields (authorization_uri, device_code)"
)

return {
"authorization_uri": authorization_uri,
"user_code": device_response.get("user_code"),
"device_code": device_code,
"interval": device_response.get("interval", 5),
"expires_in": device_response.get("expires_in", 300),
}

def _oidc_handle_token_polling_response(self, token_response):
"""
Handle non-success token polling responses.
Returns (None, new_interval) to continue polling, or raises on error.
"""
error = token_response.get("error")

if error == "authorization_pending":
return None, None
elif error == "slow_down":
return None, 5 # Signal to increase interval by 5 seconds
elif error:
raise self.RequestFailedError(
request={"method": "OIDC device code login"},
response={"msg": f"OIDC authentication failed: {error}"},
)

return None, None

def _prepare_refresh_session(self, *, refresh_token):
"""
If no refresh token is passed to API, then use the refresh token from 'auth_key'
Expand Down
84 changes: 82 additions & 2 deletions bluesky_queueserver_api/comm_threads.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import time
import webbrowser

import httpx
from bluesky_queueserver import ZMQCommSendThreads

Expand Down Expand Up @@ -172,11 +175,88 @@ def send_request(
def login(self, username=None, *, password=None, provider=None):
# Docstring is maintained separately
endpoint, data = self._prepare_login(username=username, password=password, provider=provider)

if self._is_external_auth(endpoint):
response = self._oidc_device_code_login(endpoint=endpoint)
else:
response = self._password_login(endpoint=endpoint, data=data)

return response

def _password_login(self, endpoint, data):
"""Perform standard password-based login."""
response = self.send_request(
method=("POST", endpoint), data=data, timeout=self._timeout_login, auto_refresh_session=False
)
response = self._process_login_response(response=response)
return response
return self._process_login_response(response=response)

def _oidc_device_code_login(self, endpoint):
"""
Perform OIDC login using the device code flow.
Opens a browser for user authentication and polls for completion.
"""
device_params = self._initiate_device_code_flow(endpoint)

self._oidc_prompt_user_for_auth(device_params)
webbrowser.open(device_params["authorization_uri"])

token_endpoint = endpoint.replace("/authorize", "/token")
return self._poll_for_token(
token_endpoint=token_endpoint,
device_code=device_params["device_code"],
interval=device_params["interval"],
expires_in=device_params["expires_in"],
)

def _initiate_device_code_flow(self, endpoint):
"""Initiate OIDC device code flow and return device parameters."""
device_response = self.send_request(
method=("POST", endpoint), timeout=self._timeout_login, auto_refresh_session=False
)
return self._oicd_handle_initial_response(device_response)

def _poll_for_token(self, token_endpoint, device_code, interval, expires_in):
"""Poll the token endpoint until authentication completes or times out."""

start_time = time.time()

while (time.time() - start_time) < expires_in:
time.sleep(interval)

result, new_interval = self._attempt_token_request(token_endpoint, device_code)
if result is not None:
return result
if new_interval is not None:
interval = new_interval

raise self.RequestTimeoutError(
"OIDC authentication timed out waiting for user authorization",
request={"method": "OIDC device code login", "endpoint": token_endpoint},
)

def _attempt_token_request(self, token_endpoint, device_code):
"""
Attempt a single token request.
Returns (response, None) on success, (None, new_interval) to continue polling,
or raises an exception on error.
"""
try:
token_response = self.send_request(
method=("POST", token_endpoint),
params={"device_code": device_code},
timeout=self._timeout_login,
auto_refresh_session=False,
)

if token_response.get("access_token"):
return self._process_login_response(response=token_response), None

return self._oidc_handle_token_polling_response(token_response)

except self.HTTPClientError as ex:
if "authorization_pending" in str(ex).lower():
return None, None
raise

def session_refresh(self, *, refresh_token=None):
# Docstring is maintained separately
Expand Down
Loading
Loading