From 6db9eb6190bf416c5adcd48f3ad9744f521e5871 Mon Sep 17 00:00:00 2001 From: mxatmx Date: Sat, 13 Jun 2026 06:40:32 -0400 Subject: [PATCH 1/2] feat: add OIDC SSO authentication Add OpenID Connect single sign-on, mirroring the existing SAML flow. - config: OIDC_ENABLED, OIDC_IDP_NAME, OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SCOPES, configurable claim names and OIDC_SKIP_2FA - utils/oidc.py: Authlib client factory + claim-mapping helpers - app init: register the OIDC client on app.extensions when enabled - auth routes: /auth/oidc/login (redirect with PKCE) and /auth/oidc/callback (validate ID token, match user by email or auto-provision, set JWT cookies) - honour ENFORCE_2FA unless OIDC_SKIP_2FA is set - reject logins whose email_verified claim is explicitly false - expose oidc_enabled / oidc_idp_name via /api/config - add Authlib dependency - tests for claim mapping, provisioning/linking, email-verified and 2FA gating - document the OIDC configuration variables Co-Authored-By: Claude Opus 4.8 (1M context) --- setup.cfg | 1 + specs/configuration.md | 42 +++++++ tests/auth/test_oidc.py | 173 ++++++++++++++++++++++++++ zou/app/__init__.py | 4 + zou/app/blueprints/auth/__init__.py | 4 + zou/app/blueprints/auth/resources.py | 116 +++++++++++++++++ zou/app/blueprints/index/resources.py | 2 + zou/app/config.py | 11 ++ zou/app/utils/oidc.py | 63 ++++++++++ 9 files changed, 416 insertions(+) create mode 100644 tests/auth/test_oidc.py create mode 100644 zou/app/utils/oidc.py diff --git a/setup.cfg b/setup.cfg index a0e9cf9fa..2880f1bd2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,7 @@ classifiers = zip_safe = False packages = find: install_requires = + Authlib==1.7.2 babel==2.18.0 click==8.3.3 discord.py==2.7.1 diff --git a/specs/configuration.md b/specs/configuration.md index 380b26fb9..bba2cd9ed 100644 --- a/specs/configuration.md +++ b/specs/configuration.md @@ -74,3 +74,45 @@ All configuration is in `zou/app/config.py`, read from environment variables. | `LDAP_BASE_DN` | | LDAP base distinguished name | | `SAML_ENABLED` | false | Enable SAML SSO | | `SAML_METADATA_URL` | | SAML IdP metadata URL | +| `SAML_IDP_NAME` | | Display name shown on the SAML login button | + +## OIDC + +OpenID Connect single sign-on. When enabled, a "Login with " button +is shown on the login page; users are redirected to the provider, and on return +a matching Kitsu account is found by email (or created on first login). + +| Variable | Default | Description | +|----------|---------|-------------| +| `OIDC_ENABLED` | false | Enable OIDC SSO | +| `OIDC_IDP_NAME` | | Display name shown on the OIDC login button | +| `OIDC_DISCOVERY_URL` | | Provider OpenID configuration URL (ends with `/.well-known/openid-configuration`) | +| `OIDC_CLIENT_ID` | | OAuth client identifier registered with the provider | +| `OIDC_CLIENT_SECRET` | | OAuth client secret | +| `OIDC_SCOPES` | `openid email profile` | Space-separated scopes to request | +| `OIDC_EMAIL_CLAIM` | `email` | Claim used as the account email | +| `OIDC_GIVEN_NAME_CLAIM` | `given_name` | Claim used for the first name | +| `OIDC_FAMILY_NAME_CLAIM` | `family_name` | Claim used for the last name | +| `OIDC_SKIP_2FA` | false | When true, OIDC sessions skip Kitsu's 2FA setup gate (trust the IdP for MFA). When false, `ENFORCE_2FA` applies as usual. | + +The redirect URI to register with the provider is +`:///api/auth/oidc/callback`. + +### Example: Keycloak + +``` +OIDC_ENABLED=true +OIDC_IDP_NAME=Keycloak +OIDC_DISCOVERY_URL=https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration +OIDC_CLIENT_ID=kitsu +OIDC_CLIENT_SECRET= +``` + +Register `https://kitsu.example.com/api/auth/oidc/callback` as a valid redirect +URI on the Keycloak client. The same shape works for Azure AD, Okta, and Google +by pointing `OIDC_DISCOVERY_URL` at the provider's discovery document and, if the +provider uses non-standard claim names, overriding the `OIDC_*_CLAIM` variables. + +> OIDC requires Flask's signed-cookie session to carry the `state`/`nonce`/PKCE +> values between `/auth/oidc/login` and `/auth/oidc/callback`, so `SECRET_KEY` +> must be set (it already is in any standard deployment). diff --git a/tests/auth/test_oidc.py b/tests/auth/test_oidc.py new file mode 100644 index 000000000..221913c21 --- /dev/null +++ b/tests/auth/test_oidc.py @@ -0,0 +1,173 @@ +from unittest import mock + +from flask_jwt_extended import create_access_token as real_create_access_token + +from tests.base import ApiDBTestCase + +from zou.app import config +from zou.app.services import persons_service +from zou.app.utils import oidc + + +class OIDCClaimMappingTestCase(ApiDBTestCase): + """Unit tests for the pure claim-mapping helpers.""" + + def test_get_email_default_claim(self): + self.assertEqual( + oidc.get_email_from_claims({"email": "jane@example.com"}), + "jane@example.com", + ) + + def test_get_email_overridden_claim(self): + with mock.patch.object(config, "OIDC_EMAIL_CLAIM", "mail"): + self.assertEqual( + oidc.get_email_from_claims({"mail": "jane@corp.com"}), + "jane@corp.com", + ) + + def test_map_claims_default_claims(self): + person_info = oidc.map_claims( + {"given_name": "Jane", "family_name": "Doe"} + ) + self.assertEqual( + person_info, {"first_name": "Jane", "last_name": "Doe"} + ) + + def test_map_claims_overridden_claims(self): + with mock.patch.object( + config, "OIDC_GIVEN_NAME_CLAIM", "firstName" + ), mock.patch.object(config, "OIDC_FAMILY_NAME_CLAIM", "surname"): + person_info = oidc.map_claims( + {"firstName": "Akira", "surname": "Tanaka"} + ) + self.assertEqual( + person_info, {"first_name": "Akira", "last_name": "Tanaka"} + ) + + def test_map_claims_omits_missing_fields(self): + self.assertEqual(oidc.map_claims({"given_name": "Jane"}), { + "first_name": "Jane" + }) + self.assertEqual(oidc.map_claims({}), {}) + + def test_is_email_verified(self): + self.assertTrue(oidc.is_email_verified({})) + self.assertTrue(oidc.is_email_verified({"email_verified": True})) + self.assertFalse(oidc.is_email_verified({"email_verified": False})) + + +class OIDCCallbackTestCase(ApiDBTestCase): + """Tests for the OIDC callback: provisioning, linking and 2FA gating.""" + + def setUp(self): + super().setUp() + self._oidc_enabled = config.OIDC_ENABLED + self._enforce_2fa = config.ENFORCE_2FA + self._skip_2fa = config.OIDC_SKIP_2FA + config.OIDC_ENABLED = True + config.ENFORCE_2FA = False + config.OIDC_SKIP_2FA = False + + def tearDown(self): + config.OIDC_ENABLED = self._oidc_enabled + config.ENFORCE_2FA = self._enforce_2fa + config.OIDC_SKIP_2FA = self._skip_2fa + super().tearDown() + + def mock_client(self, claims): + """Return a mock OIDC client yielding the given claims as userinfo.""" + client = mock.Mock() + client.authorize_access_token.return_value = {"userinfo": claims} + return client + + def call_callback(self, claims): + with mock.patch.object( + oidc, "get_oidc_client", return_value=self.mock_client(claims) + ): + return self.app.get("auth/oidc/callback") + + def test_disabled_returns_400(self): + config.OIDC_ENABLED = False + response = self.app.get("auth/oidc/callback") + self.assertEqual(response.status_code, 400) + + def test_creates_user_on_first_login(self): + email = "newcomer@example.com" + self.assertRaises( + Exception, persons_service.get_person_by_email, email + ) + response = self.call_callback( + { + "email": email, + "given_name": "New", + "family_name": "Comer", + } + ) + self.assertEqual(response.status_code, 302) + person = persons_service.get_person_by_email(email) + self.assertEqual(person["first_name"], "New") + self.assertEqual(person["last_name"], "Comer") + self.assertEqual(person["role"], "user") + + def test_links_existing_user_by_email(self): + self.generate_fixture_person() + existing = self.person.serialize() + response = self.call_callback( + { + "email": existing["email"], + "given_name": "Updated", + "family_name": "Name", + } + ) + self.assertEqual(response.status_code, 302) + person = persons_service.get_person_by_email(existing["email"]) + self.assertEqual(person["id"], existing["id"]) + self.assertEqual(person["first_name"], "Updated") + + def test_missing_email_returns_400(self): + response = self.call_callback({"given_name": "No", "family_name": "Mail"}) + self.assertEqual(response.status_code, 400) + + def test_unverified_email_rejected(self): + response = self.call_callback( + {"email": "spoof@example.com", "email_verified": False} + ) + self.assertEqual(response.status_code, 400) + self.assertRaises( + Exception, + persons_service.get_person_by_email, + "spoof@example.com", + ) + + def _capture_claims(self, claims): + """Run the callback capturing the additional_claims passed to the JWT.""" + with mock.patch( + "zou.app.blueprints.auth.resources.create_access_token", + wraps=real_create_access_token, + ) as create_token: + self.call_callback(claims) + return create_token.call_args.kwargs["additional_claims"] + + def test_2fa_setup_required_when_enforced(self): + config.ENFORCE_2FA = True + config.OIDC_SKIP_2FA = False + additional_claims = self._capture_claims( + { + "email": "needs2fa@example.com", + "given_name": "Needs", + "family_name": "Tfa", + } + ) + self.assertTrue(additional_claims.get("requires_2fa_setup")) + + def test_skip_2fa_bypasses_setup_gate(self): + config.ENFORCE_2FA = True + config.OIDC_SKIP_2FA = True + additional_claims = self._capture_claims( + { + "email": "skip2fa@example.com", + "given_name": "Skip", + "family_name": "Tfa", + } + ) + self.assertNotIn("requires_2fa_setup", additional_claims) diff --git a/zou/app/__init__.py b/zou/app/__init__.py index 4611e1706..e696708ec 100644 --- a/zou/app/__init__.py +++ b/zou/app/__init__.py @@ -48,6 +48,7 @@ ) from zou.app.utils.saml import saml_client_for +from zou.app.utils.oidc import oidc_client_for from zou.app.utils.fido import get_fido_server app = Flask(__name__) @@ -87,6 +88,9 @@ if config.SAML_ENABLED: app.extensions["saml_client"] = saml_client_for(config.SAML_METADATA_URL) +if config.OIDC_ENABLED: + app.extensions["oidc_client"] = oidc_client_for(app) + app.extensions["fido_server"] = get_fido_server() if config.INDEXER["key"] is not None: diff --git a/zou/app/blueprints/auth/__init__.py b/zou/app/blueprints/auth/__init__.py index 89836f558..e6c8c7a15 100644 --- a/zou/app/blueprints/auth/__init__.py +++ b/zou/app/blueprints/auth/__init__.py @@ -15,6 +15,8 @@ TOTPResource, SAMLSSOResource, SAMLLoginResource, + OIDCLoginResource, + OIDCCallbackResource, ) routes = [ @@ -31,6 +33,8 @@ ("/auth/fido", FIDOResource), ("/auth/saml/sso", SAMLSSOResource), ("/auth/saml/login", SAMLLoginResource), + ("/auth/oidc/login", OIDCLoginResource), + ("/auth/oidc/callback", OIDCCallbackResource), ] blueprint = Blueprint("auth", "auth") diff --git a/zou/app/blueprints/auth/resources.py b/zou/app/blueprints/auth/resources.py index 79d2ea9d8..e82138bfe 100644 --- a/zou/app/blueprints/auth/resources.py +++ b/zou/app/blueprints/auth/resources.py @@ -50,6 +50,7 @@ from zou.app.utils.flask import is_from_browser from zou.app.utils.saml import saml_client_for +from zou.app.utils import oidc from zou.app.stores import auth_tokens_store from zou.app.services.exception import ( @@ -1584,3 +1585,118 @@ def get(self): redirect_url = value return redirect(redirect_url, code=302) + + +class OIDCLoginResource(Resource, ArgsMixin): + def get(self): + """ + OIDC SSO login redirect + --- + description: Initiate OIDC SSO login by redirecting to the OpenID + Connect identity provider. + tags: + - Authentication + responses: + 302: + description: Redirect to OIDC identity provider + 400: + description: OIDC not enabled + """ + if not config.OIDC_ENABLED: + return {"error": "OIDC is not enabled."}, 400 + + redirect_uri = ( + f"{config.DOMAIN_PROTOCOL}://{config.DOMAIN_NAME}" + "/api/auth/oidc/callback" + ) + return oidc.get_oidc_client().authorize_redirect(redirect_uri) + + +class OIDCCallbackResource(Resource, ArgsMixin): + def get(self): + """ + OIDC SSO callback + --- + description: Handle the OIDC SSO callback. Exchanges the authorization + code, validates the ID token, then logs in the matching user + (creating one on first login when none exists). + tags: + - Authentication + responses: + 302: + description: Login successful, redirect to home page + 400: + description: OIDC not enabled or email not verified + """ + if not config.OIDC_ENABLED: + return {"error": "OIDC is not enabled."}, 400 + + token = oidc.get_oidc_client().authorize_access_token() + claims = token.get("userinfo") or {} + + email = oidc.get_email_from_claims(claims) + if not email: + return {"error": "No email claim returned by the provider."}, 400 + if not oidc.is_email_verified(claims): + return {"error": "Email address is not verified."}, 400 + + person_info = oidc.map_claims(claims) + + try: + user = persons_service.get_person_by_email(email) + for k, v in person_info.items(): + if user.get(k) != v: + persons_service.update_person( + user["id"], person_info, bypass_protected_accounts=True + ) + break + except PersonNotFoundException: + random_password = auth.encrypt_password(secrets.token_urlsafe(48)) + # first_name/last_name are required by create_person; default them + # in case the provider did not return the corresponding claims. + create_info = {"first_name": "", "last_name": "", **person_info} + user = persons_service.create_person( + email, random_password, **create_info + ) + + response = make_response( + redirect(f"{config.DOMAIN_PROTOCOL}://{config.DOMAIN_NAME}") + ) + + if user["active"]: + # Honour 2FA enforcement unless OIDC sessions are configured to + # skip it (e.g. when the identity provider already enforces MFA). + requires_2fa_setup = False + if config.ENFORCE_2FA and not config.OIDC_SKIP_2FA: + if not auth_service.is_user_exempt_from_2fa(user, app): + if not auth_service.person_two_factor_authentication_enabled( + user + ): + requires_2fa_setup = True + + additional_claims = {"identity_type": "person"} + if requires_2fa_setup: + additional_claims["requires_2fa_setup"] = True + + access_token = create_access_token( + identity=user["id"], + additional_claims=additional_claims, + ) + refresh_token = create_refresh_token( + identity=user["id"], + additional_claims=additional_claims, + ) + identity_changed.send( + current_app._get_current_object(), + identity=Identity(user["id"], "person"), + ) + + ip_address = request.environ.get( + "HTTP_X_REAL_IP", request.remote_addr + ) + + set_access_cookies(response, access_token) + set_refresh_cookies(response, refresh_token) + events_service.create_login_log(user["id"], ip_address, "web") + + return response diff --git a/zou/app/blueprints/index/resources.py b/zou/app/blueprints/index/resources.py index ce3cbf8de..9d5bcfc4f 100644 --- a/zou/app/blueprints/index/resources.py +++ b/zou/app/blueprints/index/resources.py @@ -476,6 +476,8 @@ def get(self): "indexer_configured": config.INDEXER["key"] is not None, "saml_enabled": config.SAML_ENABLED, "saml_idp_name": config.SAML_IDP_NAME, + "oidc_enabled": config.OIDC_ENABLED, + "oidc_idp_name": config.OIDC_IDP_NAME, "default_locale": persons_service.get_default_locale(), "default_timezone": persons_service.get_default_timezone(), "enforce_2fa": config.ENFORCE_2FA, diff --git a/zou/app/config.py b/zou/app/config.py index 9cde63ae9..c8a6198f1 100644 --- a/zou/app/config.py +++ b/zou/app/config.py @@ -154,6 +154,17 @@ SAML_IDP_NAME = os.getenv("SAML_IDP_NAME", "") SAML_METADATA_URL = os.getenv("SAML_METADATA_URL", "") +OIDC_ENABLED = envtobool("OIDC_ENABLED", False) +OIDC_IDP_NAME = os.getenv("OIDC_IDP_NAME", "") +OIDC_DISCOVERY_URL = os.getenv("OIDC_DISCOVERY_URL", "") +OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "") +OIDC_SCOPES = os.getenv("OIDC_SCOPES", "openid email profile") +OIDC_EMAIL_CLAIM = os.getenv("OIDC_EMAIL_CLAIM", "email") +OIDC_GIVEN_NAME_CLAIM = os.getenv("OIDC_GIVEN_NAME_CLAIM", "given_name") +OIDC_FAMILY_NAME_CLAIM = os.getenv("OIDC_FAMILY_NAME_CLAIM", "family_name") +OIDC_SKIP_2FA = envtobool("OIDC_SKIP_2FA", False) + LOGS_MODE = os.getenv("LOGS_MODE", "default") LOGS_HOST = os.getenv("LOGS_HOST", "localhost") LOGS_PORT = os.getenv("LOGS_PORT", 2202) diff --git a/zou/app/utils/oidc.py b/zou/app/utils/oidc.py new file mode 100644 index 000000000..c5e56fccd --- /dev/null +++ b/zou/app/utils/oidc.py @@ -0,0 +1,63 @@ +from authlib.integrations.flask_client import OAuth +from flask import current_app + +from zou.app import config + + +def oidc_client_for(app): + """ + Build the Authlib OAuth registry and register the configured OIDC + provider from its discovery document. The registered client is then + reachable as ``oauth.oidc``. + """ + oauth = OAuth(app) + oauth.register( + name="oidc", + server_metadata_url=config.OIDC_DISCOVERY_URL, + client_id=config.OIDC_CLIENT_ID, + client_secret=config.OIDC_CLIENT_SECRET, + client_kwargs={"scope": config.OIDC_SCOPES}, + ) + return oauth + + +def get_oidc_client(): + """ + Return the registered OIDC client (``oauth.oidc``) stored on the app + extensions at startup. + """ + return current_app.extensions["oidc_client"].oidc + + +def get_email_from_claims(claims): + """ + Resolve the user email from the OIDC claims using the configured claim + name (``OIDC_EMAIL_CLAIM``, defaults to the standard ``email`` claim). + """ + return claims.get(config.OIDC_EMAIL_CLAIM) + + +def is_email_verified(claims): + """ + Return whether the email can be trusted. We only reject when the + provider explicitly states the email is not verified; an absent + ``email_verified`` claim is treated as verified to stay compatible with + providers that do not emit it. + """ + return claims.get("email_verified", True) is not False + + +def map_claims(claims): + """ + Map OIDC claims to person fields using the configured claim names. Only + populated fields are returned so existing values are not overwritten with + empty strings. + """ + person_info = {} + first_name = claims.get(config.OIDC_GIVEN_NAME_CLAIM) + last_name = claims.get(config.OIDC_FAMILY_NAME_CLAIM) + if first_name: + person_info["first_name"] = first_name + if last_name: + person_info["last_name"] = last_name + return person_info From abee652d2bfcddc6dca2f0358afc03d3810b8c66 Mon Sep 17 00:00:00 2001 From: mxatmx Date: Sat, 13 Jun 2026 07:35:05 -0400 Subject: [PATCH 2/2] fix: pin Authlib to 1.6.12 to avoid pysaml2/cryptography conflict Authlib 1.7.x depends on joserfc, which requires cryptography>=45. pysaml2 caps pyopenssl<24.3.0, and every pyopenssl in that range caps cryptography<44. The resolver reconciles this by falling back to the ancient pyopenssl 22.0.0 (no upper cryptography bound) + cryptography 48, a pair that is broken at runtime (ImportError on OpenSSL X509 flags) and made the whole test suite INTERNALERROR at import time. Authlib 1.6.x has no joserfc dependency (requires only 'cryptography'), so the resolver settles on pyopenssl 24.2.1 + cryptography 43.x, which is compatible. The OIDC code uses only stable flask_client APIs present in 1.6.x. Co-Authored-By: Claude Opus 4.8 (1M context) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 2880f1bd2..56c6bc0b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ classifiers = zip_safe = False packages = find: install_requires = - Authlib==1.7.2 + Authlib==1.6.12 babel==2.18.0 click==8.3.3 discord.py==2.7.1