diff --git a/setup.cfg b/setup.cfg index a0e9cf9fa..56c6bc0b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,7 @@ classifiers = zip_safe = False packages = find: install_requires = + Authlib==1.6.12 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