From 3de2b5e042829c51bc8e0939706d5c82ac869c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Tue, 19 May 2026 09:24:39 +0200 Subject: [PATCH 01/10] feat(auth): implement OIDC authentifier fix #76 --- CHANGELOG.md | 2 + .../rfl/authentication/errors.py | 4 + src/authentication/rfl/authentication/oidc.py | 155 ++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/authentication/rfl/authentication/oidc.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 925aadf..f6b82ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to `rfl.build.testing.params` as alternative to parameterized library (#75). - settings: Support optional section-level documentation via `_doc` in settings definition (#80). +- auth: Add `OIDCAuthentifier` for OpenID Connect Authorization Code flow with + authlib (#76). ## [1.7.0] - 2026-05-08 diff --git a/src/authentication/rfl/authentication/errors.py b/src/authentication/rfl/authentication/errors.py index a9c8772..fa58026 100644 --- a/src/authentication/rfl/authentication/errors.py +++ b/src/authentication/rfl/authentication/errors.py @@ -15,6 +15,10 @@ class LDAPAuthenticationError(AuthenticationError): pass +class OIDCAuthenticationError(AuthenticationError): + pass + + class JWTError(AuthenticationError): pass diff --git a/src/authentication/rfl/authentication/oidc.py b/src/authentication/rfl/authentication/oidc.py new file mode 100644 index 0000000..f7a4c3b --- /dev/null +++ b/src/authentication/rfl/authentication/oidc.py @@ -0,0 +1,155 @@ +# Copyright (c) 2026 Rackslab +# +# This file is part of RFL. +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +from typing import Any, Dict, List, Mapping, Optional, Union +from pathlib import Path +import logging + +try: + from authlib.integrations.base_client import OAuthError + from authlib.integrations.flask_client import OAuth, token_update +except ImportError as err: + raise ImportError("Authlib is required for RFL OIDC Authentication") from err + +from .errors import OIDCAuthenticationError +from .user import AuthenticatedUser + +logger = logging.getLogger(__name__) + +_OIDC_CLIENT_NAME = "rfl_oidc" + +__all__ = [ + "OIDCClient", + "OAuthError", + "token_update", +] + + +class OIDCClient: + """OpenID Connect client for Flask apps using Authlib's Flask integration.""" + + def __init__( + self, + app, + *, + issuer: str, + client_id: str, + client_secret: str, + redirect_uri: str, + scope: str = "openid profile email", + subject_claim: str = "sub", + fullname_claim: str = "name", + groups_claim: Optional[str] = "groups", + restricted_groups: Optional[List[str]] = None, + verify_ssl: bool = True, + cacert: Optional[Path] = None, + code_challenge_method: Optional[str] = "S256", + fetch_token=None, + update_token=None, + cache=None, + ): + self.issuer = issuer.rstrip("/") + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.scope = scope + self.subject_claim = subject_claim + self.fullname_claim = fullname_claim + self.groups_claim = groups_claim + self.restricted_groups = restricted_groups + + client_kwargs: Dict[str, Any] = { + "scope": scope, + "verify": self._verify(verify_ssl, cacert), + } + if code_challenge_method is not None: + client_kwargs["code_challenge_method"] = code_challenge_method + + metadata_url = f"{self.issuer}/.well-known/openid-configuration" + oauth = OAuth( + app, + cache=cache, + fetch_token=fetch_token, + update_token=update_token, + ) + self._client = oauth.register( + _OIDC_CLIENT_NAME, + client_id=client_id, + client_secret=client_secret, + server_metadata_url=metadata_url, + client_kwargs=client_kwargs, + ) + logger.debug( + "Initialized OIDC client for issuer %s (client_id=%s, redirect_uri=%s)", + self.issuer, + self.client_id, + self.redirect_uri, + ) + + @staticmethod + def _verify(verify_ssl: bool, cacert: Optional[Path]) -> Union[bool, str]: + if cacert is not None: + return str(cacert) + return verify_ssl + + def redirect(self, redirect_uri=None, **kwargs): + """Create HTTP redirect to the OIDC authorization endpoint.""" + return self._client.authorize_redirect( + redirect_uri=redirect_uri or self.redirect_uri, + **kwargs, + ) + + def authenticate(self, **kwargs) -> AuthenticatedUser: + """Complete the authorization code flow and return an AuthenticatedUser.""" + token = self._client.authorize_access_token(**kwargs) + user = self._user_from_token(token) + if not self._allowed_groups(user.groups): + raise OIDCAuthenticationError( + f"User {user.login} is not member of restricted groups" + ) + logger.info("OIDC authentication completed for user %s", user.login) + return user + + def _user_from_token(self, token: Mapping[str, Any]) -> AuthenticatedUser: + userinfo = token.get("userinfo") + if userinfo is None: + raise OIDCAuthenticationError( + "OIDC token response does not contain validated userinfo" + ) + return self._claims_to_user(userinfo) + + def _normalize_groups(self, value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(group) for group in value] + return [str(value)] + + def _claims_to_user(self, claims: Dict[str, Any]) -> AuthenticatedUser: + login = claims.get(self.subject_claim) + if not login: + raise OIDCAuthenticationError( + f"OIDC claims do not contain subject claim {self.subject_claim}" + ) + + fullname = claims.get(self.fullname_claim) + groups = [] + if self.groups_claim is not None: + groups = self._normalize_groups(claims.get(self.groups_claim)) + + return AuthenticatedUser( + login=str(login), + fullname=str(fullname) if fullname is not None else None, + groups=groups, + ) + + def _allowed_groups(self, groups: List[str]) -> bool: + """Return False if restricted groups are set and none of the groups match.""" + return not ( + self.restricted_groups is not None + and len(self.restricted_groups) + and not any(group in self.restricted_groups for group in groups) + ) From 06f02c7181afd50e80f03de3d1dcbc616204181b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Tue, 19 May 2026 09:25:38 +0200 Subject: [PATCH 02/10] chore: declare dependency on Authlib external lib --- pyproject.toml | 2 ++ src/authentication/pyproject.toml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7da9768..cc4b85d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,13 @@ authors = [ [project.optional-dependencies] tests = [ + "Authlib", "Flask", "PyJWT", "pytest", "python-ldap", "PyYAML", + "requests", ] [tool.pytest.ini_options] diff --git a/src/authentication/pyproject.toml b/src/authentication/pyproject.toml index 6de4e8d..1682df6 100644 --- a/src/authentication/pyproject.toml +++ b/src/authentication/pyproject.toml @@ -26,6 +26,9 @@ classifiers = [ ] readme = "README.md" +[project.optional-dependencies] +oidc = ["Authlib", "Flask", "requests"] + [project.urls] "Homepage" = "https://github.com/rackslab/RFL" "Bug Tracker" = "https://github.com/rackslab/RFL/issues" From d1f87ec97b3dcb03779d469a51ffdd57ca001b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Tue, 19 May 2026 09:26:17 +0200 Subject: [PATCH 03/10] tests(auth): cover OIDCAuthentifier --- src/authentication/rfl/tests/test_oidc.py | 242 ++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 src/authentication/rfl/tests/test_oidc.py diff --git a/src/authentication/rfl/tests/test_oidc.py b/src/authentication/rfl/tests/test_oidc.py new file mode 100644 index 0000000..a4f4fe0 --- /dev/null +++ b/src/authentication/rfl/tests/test_oidc.py @@ -0,0 +1,242 @@ +# Copyright (c) 2026 Rackslab +# +# This file is part of RFL. +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +import unittest +from unittest.mock import MagicMock, patch + +import flask + +try: + from authlib.integrations.base_client import OAuthError + + from rfl.authentication.errors import OIDCAuthenticationError + from rfl.authentication.oidc import ( + OIDCClient, + OAuthError as RFL_OAuthError, + token_update, + ) + from rfl.authentication.user import AuthenticatedUser +except ImportError: + AUTHLIB_AVAILABLE = False +else: + AUTHLIB_AVAILABLE = True + + +@unittest.skipUnless(AUTHLIB_AVAILABLE, "authlib is not installed") +class TestOIDCClient(unittest.TestCase): + def setUp(self): + self.app = flask.Flask(__name__) + self.app.secret_key = "test-secret" + + def _make_client(self, **kwargs): + defaults = { + "issuer": "https://idp.example.com", + "client_id": "client-id", + "client_secret": "client-secret", + "redirect_uri": "https://app.example.com/callback", + } + defaults.update(kwargs) + with self.app.app_context(): + return OIDCClient(self.app, **defaults) + + @patch("rfl.authentication.oidc.OAuth") + def test_init_registers_client(self, mock_oauth_cls): + mock_oauth = mock_oauth_cls.return_value + mock_client = MagicMock() + mock_oauth.register.return_value = mock_client + + client = self._make_client(scope="openid email", cacert="/etc/ssl/ca.pem") + + mock_oauth_cls.assert_called_once_with( + self.app, + cache=None, + fetch_token=None, + update_token=None, + ) + mock_oauth.register.assert_called_once_with( + "rfl_oidc", + client_id="client-id", + client_secret="client-secret", + server_metadata_url="https://idp.example.com/.well-known/openid-configuration", + client_kwargs={ + "scope": "openid email", + "verify": "/etc/ssl/ca.pem", + "code_challenge_method": "S256", + }, + ) + self.assertIs(client._client, mock_client) + + @patch("rfl.authentication.oidc.OAuth") + def test_init_without_code_challenge_method(self, mock_oauth_cls): + mock_oauth_cls.return_value.register.return_value = MagicMock() + + self._make_client(code_challenge_method=None) + + register_kwargs = mock_oauth_cls.return_value.register.call_args.kwargs + self.assertNotIn("code_challenge_method", register_kwargs["client_kwargs"]) + + @patch("rfl.authentication.oidc.OAuth") + def test_redirect_uses_default_redirect_uri(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_response = MagicMock() + mock_client.authorize_redirect.return_value = mock_response + + client = self._make_client() + with self.app.test_request_context(): + response = client.redirect() + + mock_client.authorize_redirect.assert_called_once_with( + redirect_uri="https://app.example.com/callback", + ) + self.assertIs(response, mock_response) + + @patch("rfl.authentication.oidc.OAuth") + def test_redirect_custom_redirect_uri(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + + client = self._make_client() + with self.app.test_request_context(): + client.redirect(redirect_uri="https://other.example/cb") + + mock_client.authorize_redirect.assert_called_once_with( + redirect_uri="https://other.example/cb", + ) + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_returns_authenticated_user(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.return_value = { + "userinfo": { + "sub": "alice", + "name": "Alice", + "groups": ["users", "admins"], + }, + } + + client = self._make_client() + with self.app.test_request_context(): + user = client.authenticate() + + self.assertIsInstance(user, AuthenticatedUser) + self.assertEqual(user.login, "alice") + self.assertEqual(user.fullname, "Alice") + self.assertEqual(user.groups, ["users", "admins"]) + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_custom_claims(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.return_value = { + "userinfo": { + "uid": "bob", + "displayName": "Bob", + "memberOf": "operators", + }, + } + + client = self._make_client( + subject_claim="uid", + fullname_claim="displayName", + groups_claim="memberOf", + ) + with self.app.test_request_context(): + user = client.authenticate() + + self.assertEqual(user.login, "bob") + self.assertEqual(user.fullname, "Bob") + self.assertEqual(user.groups, ["operators"]) + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_missing_userinfo(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.return_value = {"access_token": "tok"} + + client = self._make_client() + with self.app.test_request_context(): + with self.assertRaises(OIDCAuthenticationError) as ctx: + client.authenticate() + + self.assertIn("userinfo", str(ctx.exception)) + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_missing_subject(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.return_value = { + "userinfo": {"name": "Alice"}, + } + + client = self._make_client() + with self.app.test_request_context(): + with self.assertRaises(OIDCAuthenticationError) as ctx: + client.authenticate() + + self.assertIn("sub", str(ctx.exception)) + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_groups_claim_disabled(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.return_value = { + "userinfo": {"sub": "alice", "groups": ["admins"]}, + } + + client = self._make_client(groups_claim=None) + with self.app.test_request_context(): + user = client.authenticate() + + self.assertEqual(user.groups, []) + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_restricted_groups_allowed(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.return_value = { + "userinfo": {"sub": "alice", "groups": ["admins", "users"]}, + } + + client = self._make_client(restricted_groups=["admins"]) + with self.app.test_request_context(): + user = client.authenticate() + + self.assertEqual(user.login, "alice") + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_restricted_groups_rejected(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.return_value = { + "userinfo": {"sub": "alice", "groups": ["users"]}, + } + + client = self._make_client(restricted_groups=["admins"]) + with self.app.test_request_context(): + with self.assertRaises(OIDCAuthenticationError) as ctx: + client.authenticate() + + self.assertIn("restricted groups", str(ctx.exception)) + + @patch("rfl.authentication.oidc.OAuth") + def test_authenticate_oauth_error_propagates(self, mock_oauth_cls): + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.side_effect = OAuthError( + error="access_denied", + description="User denied access", + ) + + client = self._make_client() + with self.app.test_request_context(): + with self.assertRaises(OAuthError): + client.authenticate() + + def test_reexports(self): + self.assertIs(RFL_OAuthError, OAuthError) + self.assertIsNotNone(token_update) From 663818dc7e065cfd49804275ed5761eceda9532d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Tue, 19 May 2026 09:27:27 +0200 Subject: [PATCH 04/10] ci: install authlib for integration tests --- .github/workflows/ci.yaml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7df1d46..2a679f8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,7 +41,7 @@ jobs: - name: Install application run: | - pip install src/core src/authentication src/build src/log src/permissions src/settings src/web + pip install src/core "src/authentication[oidc]" src/build src/log src/permissions src/settings src/web - name: Run tests run: pytest --import-mode=importlib @@ -88,6 +88,14 @@ jobs: python3-PyYAML \ python3-tomli + # el8 and EPEL8 don't ship Authlib, it is only available in more recent + # versions. Skip installation on this distribution (along with requests, + # soft dependency of Authlib). + # OIDC tests are automatically skipped when Authlib is not installed. + - name: Install OIDC dependencies + if: ${{ matrix.envs.container != 'rockylinux/rockylinux:8' }} + run: dnf -y install python3-authlib python3-requests + # pip and setuptools on Rocky Linux 8 do not fully support PEP 517 builds from # pyproject.toml; enable PEP517_SETUP_WRAPPER on this distribution only. - name: Install application @@ -136,11 +144,13 @@ jobs: export DEBIAN_FRONTEND=noninteractive apt update apt install -y \ + python3-authlib \ python3-flask \ python3-jwt \ python3-ldap \ python3-pip \ python3-pytest \ + python3-requests \ python3-tomli \ python3-yaml \ python3-venv @@ -191,6 +201,14 @@ jobs: python3-PyYAML \ python3-tomli + # openSUSE Leap 15 doesn't ship Authlib, it is only available in more + # recent versions. Skip installation on this distribution (along with + # requests, soft dependency of Authlib). + # OIDC tests are automatically skipped when Authlib is not installed. + - name: Install OIDC dependencies + if: ${{ matrix.envs.container != 'opensuse/leap:15' }} + run: zypper -n in python3-Authlib python3-requests + # Leap 15 ships Python 3.6; wheel is required for pip builds. - name: Install python3-wheel (Leap 15) if: ${{ matrix.envs.container == 'opensuse/leap:15' }} From ebc3a79d05a2f50b65490301129c81214e095030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Wed, 20 May 2026 17:20:49 +0200 Subject: [PATCH 05/10] feat(auth): support public OIDC client Also disable PKCE by default, consumer must set it explicitely. --- src/authentication/rfl/authentication/oidc.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/authentication/rfl/authentication/oidc.py b/src/authentication/rfl/authentication/oidc.py index f7a4c3b..67060fa 100644 --- a/src/authentication/rfl/authentication/oidc.py +++ b/src/authentication/rfl/authentication/oidc.py @@ -37,8 +37,8 @@ def __init__( *, issuer: str, client_id: str, - client_secret: str, redirect_uri: str, + client_secret: Optional[str] = None, scope: str = "openid profile email", subject_claim: str = "sub", fullname_claim: str = "name", @@ -46,14 +46,15 @@ def __init__( restricted_groups: Optional[List[str]] = None, verify_ssl: bool = True, cacert: Optional[Path] = None, - code_challenge_method: Optional[str] = "S256", + pkce: Optional[str] = None, fetch_token=None, update_token=None, cache=None, ): self.issuer = issuer.rstrip("/") self.client_id = client_id - self.client_secret = client_secret + secret = client_secret if client_secret else None + self.client_secret = secret self.redirect_uri = redirect_uri self.scope = scope self.subject_claim = subject_claim @@ -61,12 +62,15 @@ def __init__( self.groups_claim = groups_claim self.restricted_groups = restricted_groups + if secret is None and pkce is None: + raise OIDCAuthenticationError("PKCE is required for public OIDC clients") + client_kwargs: Dict[str, Any] = { "scope": scope, "verify": self._verify(verify_ssl, cacert), } - if code_challenge_method is not None: - client_kwargs["code_challenge_method"] = code_challenge_method + if pkce is not None: + client_kwargs["code_challenge_method"] = pkce metadata_url = f"{self.issuer}/.well-known/openid-configuration" oauth = OAuth( @@ -78,12 +82,14 @@ def __init__( self._client = oauth.register( _OIDC_CLIENT_NAME, client_id=client_id, - client_secret=client_secret, + client_secret=secret, server_metadata_url=metadata_url, client_kwargs=client_kwargs, ) + client_type = "public" if secret is None else "confidential" logger.debug( - "Initialized OIDC client for issuer %s (client_id=%s, redirect_uri=%s)", + "Initialized %s OIDC client for issuer %s (client_id=%s, redirect_uri=%s)", + client_type, self.issuer, self.client_id, self.redirect_uri, From ce491db92dc42cfa3a1113fd3fa5f9139b99317d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Wed, 20 May 2026 17:21:45 +0200 Subject: [PATCH 06/10] tests(auth): cover OIDC public client --- src/authentication/rfl/tests/test_oidc.py | 79 +++++++++++++++++++---- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/src/authentication/rfl/tests/test_oidc.py b/src/authentication/rfl/tests/test_oidc.py index a4f4fe0..210e8e5 100644 --- a/src/authentication/rfl/tests/test_oidc.py +++ b/src/authentication/rfl/tests/test_oidc.py @@ -64,20 +64,70 @@ def test_init_registers_client(self, mock_oauth_cls): client_kwargs={ "scope": "openid email", "verify": "/etc/ssl/ca.pem", - "code_challenge_method": "S256", }, ) self.assertIs(client._client, mock_client) @patch("rfl.authentication.oidc.OAuth") - def test_init_without_code_challenge_method(self, mock_oauth_cls): + def test_init_with_pkce(self, mock_oauth_cls): + mock_oauth_cls.return_value.register.return_value = MagicMock() + + self._make_client(pkce="S256") + + register_kwargs = mock_oauth_cls.return_value.register.call_args.kwargs + self.assertEqual( + register_kwargs["client_kwargs"]["code_challenge_method"], + "S256", + ) + + @patch("rfl.authentication.oidc.OAuth") + def test_init_without_pkce(self, mock_oauth_cls): mock_oauth_cls.return_value.register.return_value = MagicMock() - self._make_client(code_challenge_method=None) + self._make_client() register_kwargs = mock_oauth_cls.return_value.register.call_args.kwargs self.assertNotIn("code_challenge_method", register_kwargs["client_kwargs"]) + @patch("rfl.authentication.oidc.OAuth") + def test_init_public_client(self, mock_oauth_cls): + mock_oauth_cls.return_value.register.return_value = MagicMock() + + client = self._make_client(client_secret=None, pkce="S256") + + mock_oauth_cls.return_value.register.assert_called_once_with( + "rfl_oidc", + client_id="client-id", + client_secret=None, + server_metadata_url="https://idp.example.com/.well-known/openid-configuration", + client_kwargs={ + "scope": "openid profile email", + "verify": True, + "code_challenge_method": "S256", + }, + ) + self.assertIsNone(client.client_secret) + + @patch("rfl.authentication.oidc.OAuth") + def test_init_public_client_empty_secret(self, mock_oauth_cls): + mock_oauth_cls.return_value.register.return_value = MagicMock() + + client = self._make_client(client_secret="", pkce="S256") + + register_kwargs = mock_oauth_cls.return_value.register.call_args.kwargs + self.assertIsNone(register_kwargs["client_secret"]) + self.assertIsNone(client.client_secret) + + @patch("rfl.authentication.oidc.OAuth") + def test_init_public_client_requires_pkce(self, mock_oauth_cls): + with self.assertRaisesRegex( + OIDCAuthenticationError, + "PKCE is required for public OIDC clients", + ): + self._make_client(client_secret=None, pkce=None) + + mock_oauth_cls.return_value.register.assert_not_called() + @patch("rfl.authentication.oidc.OAuth") def test_redirect_uses_default_redirect_uri(self, mock_oauth_cls): mock_client = MagicMock() @@ -160,11 +210,12 @@ def test_authenticate_missing_userinfo(self, mock_oauth_cls): client = self._make_client() with self.app.test_request_context(): - with self.assertRaises(OIDCAuthenticationError) as ctx: + with self.assertRaisesRegex( + OIDCAuthenticationError, + "validated userinfo", + ): client.authenticate() - self.assertIn("userinfo", str(ctx.exception)) - @patch("rfl.authentication.oidc.OAuth") def test_authenticate_missing_subject(self, mock_oauth_cls): mock_client = MagicMock() @@ -175,11 +226,12 @@ def test_authenticate_missing_subject(self, mock_oauth_cls): client = self._make_client() with self.app.test_request_context(): - with self.assertRaises(OIDCAuthenticationError) as ctx: + with self.assertRaisesRegex( + OIDCAuthenticationError, + r"subject claim sub", + ): client.authenticate() - self.assertIn("sub", str(ctx.exception)) - @patch("rfl.authentication.oidc.OAuth") def test_authenticate_groups_claim_disabled(self, mock_oauth_cls): mock_client = MagicMock() @@ -218,11 +270,12 @@ def test_authenticate_restricted_groups_rejected(self, mock_oauth_cls): client = self._make_client(restricted_groups=["admins"]) with self.app.test_request_context(): - with self.assertRaises(OIDCAuthenticationError) as ctx: + with self.assertRaisesRegex( + OIDCAuthenticationError, + r"alice.*restricted groups", + ): client.authenticate() - self.assertIn("restricted groups", str(ctx.exception)) - @patch("rfl.authentication.oidc.OAuth") def test_authenticate_oauth_error_propagates(self, mock_oauth_cls): mock_client = MagicMock() @@ -234,7 +287,7 @@ def test_authenticate_oauth_error_propagates(self, mock_oauth_cls): client = self._make_client() with self.app.test_request_context(): - with self.assertRaises(OAuthError): + with self.assertRaisesRegex(OAuthError, "access_denied"): client.authenticate() def test_reexports(self): From 02c637a429006c05b5502fb2a0d5af527681ecd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Wed, 20 May 2026 17:24:33 +0200 Subject: [PATCH 07/10] feat(auth): translate Authlib OAuthError --- src/authentication/rfl/authentication/oidc.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/authentication/rfl/authentication/oidc.py b/src/authentication/rfl/authentication/oidc.py index 67060fa..319063f 100644 --- a/src/authentication/rfl/authentication/oidc.py +++ b/src/authentication/rfl/authentication/oidc.py @@ -103,14 +103,22 @@ def _verify(verify_ssl: bool, cacert: Optional[Path]) -> Union[bool, str]: def redirect(self, redirect_uri=None, **kwargs): """Create HTTP redirect to the OIDC authorization endpoint.""" - return self._client.authorize_redirect( - redirect_uri=redirect_uri or self.redirect_uri, - **kwargs, - ) + try: + return self._client.authorize_redirect( + redirect_uri=redirect_uri or self.redirect_uri, + **kwargs, + ) + except OAuthError as err: + raise OIDCAuthenticationError( + f"OIDC authorization redirect failed: {err}" + ) from err def authenticate(self, **kwargs) -> AuthenticatedUser: """Complete the authorization code flow and return an AuthenticatedUser.""" - token = self._client.authorize_access_token(**kwargs) + try: + token = self._client.authorize_access_token(**kwargs) + except OAuthError as err: + raise OIDCAuthenticationError(f"OIDC authorization failed: {err}") from err user = self._user_from_token(token) if not self._allowed_groups(user.groups): raise OIDCAuthenticationError( From 6b5c431479359dc2520da6174b8e9667291e11dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Wed, 20 May 2026 17:24:59 +0200 Subject: [PATCH 08/10] tests(auth): cover OAuthError translations --- src/authentication/rfl/tests/test_oidc.py | 33 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/authentication/rfl/tests/test_oidc.py b/src/authentication/rfl/tests/test_oidc.py index 210e8e5..564bdc3 100644 --- a/src/authentication/rfl/tests/test_oidc.py +++ b/src/authentication/rfl/tests/test_oidc.py @@ -277,19 +277,42 @@ def test_authenticate_restricted_groups_rejected(self, mock_oauth_cls): client.authenticate() @patch("rfl.authentication.oidc.OAuth") - def test_authenticate_oauth_error_propagates(self, mock_oauth_cls): - mock_client = MagicMock() - mock_oauth_cls.return_value.register.return_value = mock_client - mock_client.authorize_access_token.side_effect = OAuthError( + def test_authenticate_oauth_error_translated(self, mock_oauth_cls): + oauth_error = OAuthError( error="access_denied", description="User denied access", ) + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_access_token.side_effect = oauth_error client = self._make_client() with self.app.test_request_context(): - with self.assertRaisesRegex(OAuthError, "access_denied"): + with self.assertRaisesRegex( + OIDCAuthenticationError, + r"OIDC authorization failed: access_denied", + ) as ctx: client.authenticate() + self.assertIs(ctx.exception.__cause__, oauth_error) + + @patch("rfl.authentication.oidc.OAuth") + def test_redirect_oauth_error_translated(self, mock_oauth_cls): + oauth_error = OAuthError(error="mismatching_state") + mock_client = MagicMock() + mock_oauth_cls.return_value.register.return_value = mock_client + mock_client.authorize_redirect.side_effect = oauth_error + + client = self._make_client() + with self.app.test_request_context(): + with self.assertRaisesRegex( + OIDCAuthenticationError, + r"OIDC authorization redirect failed: mismatching_state", + ) as ctx: + client.redirect() + + self.assertIs(ctx.exception.__cause__, oauth_error) + def test_reexports(self): self.assertIs(RFL_OAuthError, OAuthError) self.assertIsNotNone(token_update) From ae4808c1273bba166222e97bd19e3cc40adfccc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Wed, 20 May 2026 17:55:37 +0200 Subject: [PATCH 09/10] refactor(auth): do not re-export authlib symbols --- src/authentication/rfl/authentication/oidc.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/authentication/rfl/authentication/oidc.py b/src/authentication/rfl/authentication/oidc.py index 319063f..60f595a 100644 --- a/src/authentication/rfl/authentication/oidc.py +++ b/src/authentication/rfl/authentication/oidc.py @@ -10,7 +10,7 @@ try: from authlib.integrations.base_client import OAuthError - from authlib.integrations.flask_client import OAuth, token_update + from authlib.integrations.flask_client import OAuth except ImportError as err: raise ImportError("Authlib is required for RFL OIDC Authentication") from err @@ -19,18 +19,12 @@ logger = logging.getLogger(__name__) -_OIDC_CLIENT_NAME = "rfl_oidc" - -__all__ = [ - "OIDCClient", - "OAuthError", - "token_update", -] - class OIDCClient: """OpenID Connect client for Flask apps using Authlib's Flask integration.""" + _OIDC_CLIENT_NAME = "rfl_oidc" + def __init__( self, app, @@ -80,7 +74,7 @@ def __init__( update_token=update_token, ) self._client = oauth.register( - _OIDC_CLIENT_NAME, + self._OIDC_CLIENT_NAME, client_id=client_id, client_secret=secret, server_metadata_url=metadata_url, From 339e82e7e4d8db60948508f2ffac00e50305b379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Wed, 20 May 2026 17:56:02 +0200 Subject: [PATCH 10/10] tests(auth): update after authlib export removal --- src/authentication/rfl/tests/test_oidc.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/authentication/rfl/tests/test_oidc.py b/src/authentication/rfl/tests/test_oidc.py index 564bdc3..ce4d019 100644 --- a/src/authentication/rfl/tests/test_oidc.py +++ b/src/authentication/rfl/tests/test_oidc.py @@ -13,11 +13,7 @@ from authlib.integrations.base_client import OAuthError from rfl.authentication.errors import OIDCAuthenticationError - from rfl.authentication.oidc import ( - OIDCClient, - OAuthError as RFL_OAuthError, - token_update, - ) + from rfl.authentication.oidc import OIDCClient from rfl.authentication.user import AuthenticatedUser except ImportError: AUTHLIB_AVAILABLE = False @@ -312,7 +308,3 @@ def test_redirect_oauth_error_translated(self, mock_oauth_cls): client.redirect() self.assertIs(ctx.exception.__cause__, oauth_error) - - def test_reexports(self): - self.assertIs(RFL_OAuthError, OAuthError) - self.assertIsNotNone(token_update)