Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions specs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <provider>" 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
`<DOMAIN_PROTOCOL>://<DOMAIN_NAME>/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=<secret from the Keycloak client>
```

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).
173 changes: 173 additions & 0 deletions tests/auth/test_oidc.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions zou/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions zou/app/blueprints/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
TOTPResource,
SAMLSSOResource,
SAMLLoginResource,
OIDCLoginResource,
OIDCCallbackResource,
)

routes = [
Expand All @@ -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")
Expand Down
116 changes: 116 additions & 0 deletions zou/app/blueprints/auth/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions zou/app/blueprints/index/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading