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' }} 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/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" 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..60f595a --- /dev/null +++ b/src/authentication/rfl/authentication/oidc.py @@ -0,0 +1,163 @@ +# 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 +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__) + + +class OIDCClient: + """OpenID Connect client for Flask apps using Authlib's Flask integration.""" + + _OIDC_CLIENT_NAME = "rfl_oidc" + + def __init__( + self, + app, + *, + issuer: str, + client_id: str, + redirect_uri: str, + client_secret: Optional[str] = None, + 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, + pkce: Optional[str] = None, + fetch_token=None, + update_token=None, + cache=None, + ): + self.issuer = issuer.rstrip("/") + self.client_id = client_id + 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 + self.fullname_claim = fullname_claim + 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 pkce is not None: + client_kwargs["code_challenge_method"] = pkce + + 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( + self._OIDC_CLIENT_NAME, + client_id=client_id, + client_secret=secret, + server_metadata_url=metadata_url, + client_kwargs=client_kwargs, + ) + client_type = "public" if secret is None else "confidential" + logger.debug( + "Initialized %s OIDC client for issuer %s (client_id=%s, redirect_uri=%s)", + client_type, + 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.""" + 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.""" + 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( + 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) + ) diff --git a/src/authentication/rfl/tests/test_oidc.py b/src/authentication/rfl/tests/test_oidc.py new file mode 100644 index 0000000..ce4d019 --- /dev/null +++ b/src/authentication/rfl/tests/test_oidc.py @@ -0,0 +1,310 @@ +# 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 + 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", + }, + ) + self.assertIs(client._client, mock_client) + + @patch("rfl.authentication.oidc.OAuth") + 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() + + 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() + 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.assertRaisesRegex( + OIDCAuthenticationError, + "validated userinfo", + ): + client.authenticate() + + @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.assertRaisesRegex( + OIDCAuthenticationError, + r"subject claim sub", + ): + client.authenticate() + + @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.assertRaisesRegex( + OIDCAuthenticationError, + r"alice.*restricted groups", + ): + client.authenticate() + + @patch("rfl.authentication.oidc.OAuth") + 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( + 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)