diff --git a/auth/libraries/pip-auth/osauthlib/__init__.py b/auth/libraries/pip-auth/osauthlib/__init__.py index 9e2e8e3d..0d8424c2 100644 --- a/auth/libraries/pip-auth/osauthlib/__init__.py +++ b/auth/libraries/pip-auth/osauthlib/__init__.py @@ -11,3 +11,4 @@ InstanceError, InvalidCredentialsException, ) +from .oidc_authenticator import OidcAuthenticator # noqa diff --git a/auth/libraries/pip-auth/osauthlib/auth_handler.py b/auth/libraries/pip-auth/osauthlib/auth_handler.py index 5e9a2861..86df22a1 100644 --- a/auth/libraries/pip-auth/osauthlib/auth_handler.py +++ b/auth/libraries/pip-auth/osauthlib/auth_handler.py @@ -1,12 +1,13 @@ -from typing import Any, Optional, Tuple +from typing import Any, Callable, Dict, Optional, Tuple from requests import Response -from .constants import ANONYMOUS_USER +from .constants import ANONYMOUS_USER, AUTHENTICATION_HEADER, COOKIE_NAME from .database import Database from .exceptions import AuthorizationException from .hashing_handler import HashingHandler from .http_handler import HttpHandler +from .oidc_authenticator import OidcAuthenticator from .session_handler import SessionHandler from .token_factory import TokenFactory from .validator import Validator @@ -18,11 +19,20 @@ class AuthHandler: also access-tokens, if they are expired. It is necessary to pass a url to the auth-service for requests to that service. A function to print debug-messages can optionally be passed. + + For OIDC/Keycloak authentication, the handler can be configured with OIDC settings + to validate RS256 tokens directly. """ TOKEN_DB_KEY = "tokens" - def __init__(self, debug_fn: Any = print) -> None: + def __init__( + self, + debug_fn: Any = print, + oidc_issuer: Optional[str] = None, + oidc_audience: Optional[str] = None, + user_lookup_fn: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, + ) -> None: self.debug_fn = debug_fn self.http_handler = HttpHandler(debug_fn) self.validator = Validator(self.http_handler, debug_fn) @@ -31,20 +41,105 @@ def __init__(self, debug_fn: Any = print) -> None: self.database = Database(AuthHandler.TOKEN_DB_KEY, debug_fn) self.session_handler = SessionHandler(debug_fn) + # OIDC configuration + self.oidc_issuer = oidc_issuer + self.oidc_audience = oidc_audience + self._oidc_authenticator: Optional[OidcAuthenticator] = None + # User lookup function for keycloak_id resolution + self.user_lookup_fn = user_lookup_fn + + @property + def oidc_authenticator(self) -> Optional[OidcAuthenticator]: + """Lazy-load OIDC authenticator if configured.""" + if self._oidc_authenticator is None and self.oidc_issuer and self.oidc_audience: + self._oidc_authenticator = OidcAuthenticator( + issuer=self.oidc_issuer, + audience=self.oidc_audience, + debug_fn=self.debug_fn, + ) + return self._oidc_authenticator + + def configure_oidc( + self, + issuer: str, + audience: str, + user_lookup_fn: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, + ) -> None: + """ + Configure OIDC authentication settings. + + Args: + issuer: The OIDC token issuer URL (Keycloak realm URL) + audience: The expected audience (client_id) + user_lookup_fn: Optional function to lookup user by keycloak_id. + Takes keycloak_id string, returns user dict or None. + """ + self.oidc_issuer = issuer + self.oidc_audience = audience + self._oidc_authenticator = None # Reset to force re-creation + if user_lookup_fn is not None: + self.user_lookup_fn = user_lookup_fn + + def set_user_lookup_fn( + self, user_lookup_fn: Callable[[str], Optional[Dict[str, Any]]] + ) -> None: + """ + Set the user lookup function for keycloak_id resolution. + + Args: + user_lookup_fn: Function that takes a keycloak_id string and returns + a user dict with at least 'id' and optionally 'is_active', + or None if user not found. + """ + self.user_lookup_fn = user_lookup_fn + def authenticate( self, access_token: Optional[str], refresh_id: Optional[str] ) -> Tuple[int, Optional[str]]: """ Tries to check and read a user_id from a given access_token and refresh_id. + + Supports both OpenSlides tokens (HS256) and OIDC/Keycloak tokens (RS256). + OIDC tokens are detected by their algorithm and validated via JWKS. """ self.debug_fn("Try to authenticate with") self.debug_fn(f"AccessToken: {access_token}") self.debug_fn(f"RefreshId: {refresh_id}") - if not access_token or not refresh_id: - self.debug_fn("No access_token or refresh_id") + + if not access_token: + self.debug_fn("No access_token") + return ANONYMOUS_USER, None + + # Check if this is an OIDC token (RS256) + raw_token = self._extract_bearer_token(access_token) + if raw_token and self.oidc_authenticator: + if self.oidc_authenticator.is_oidc_token(raw_token): + self.debug_fn("Detected OIDC token, validating via JWKS") + if self.user_lookup_fn: + # Use keycloak_id lookup to resolve user + self.debug_fn("Using keycloak_id lookup to resolve user") + user_id = self.oidc_authenticator.resolve_user_by_keycloak_id( + raw_token, self.user_lookup_fn + ) + else: + # Fallback to extracting user_id from token claim + self.debug_fn("Extracting user_id from token claim") + user_id = self.oidc_authenticator.extract_user_id(raw_token) + # OIDC tokens don't have a new access token to return + return user_id, None + + # Fall back to standard OpenSlides authentication + if not refresh_id: + self.debug_fn("No refresh_id for standard auth") return ANONYMOUS_USER, None return self.validator.verify(access_token, refresh_id) + def _extract_bearer_token(self, token: str) -> Optional[str]: + """Extract the token from 'bearer ' format.""" + if token and len(token) > 7 and token.lower().startswith("bearer "): + return token[7:] + return None + def authenticate_only_refresh_id(self, refresh_id: Optional[str]) -> int: """ This tries to check and read a user_id from a given refresh_id. It only returns @@ -82,3 +177,45 @@ def clear_all_sessions(self, access_token: str, refresh_id: str) -> None: def clear_sessions_by_user_id(self, user_id: int) -> None: return self.session_handler.clear_sessions_by_user_id(user_id) + + def sso_login(self, user_id: int) -> Tuple[str, str]: + """ + Create a session for a user via SSO (OIDC/SAML) login. + + Calls the internal auth service endpoint to create a session for the + given user ID. Returns the access token and refresh cookie for the session. + + Args: + user_id: The OpenSlides user ID to create a session for. + + Returns: + Tuple of (access_token, refresh_cookie) + + Raises: + AuthenticateException: If session creation fails. + """ + from .exceptions import AuthenticateException + + self.debug_fn(f"SSO login for user_id: {user_id}") + response = self.http_handler.send_internal_request( + "sso-login", {"userId": user_id} + ) + if response.status_code != 200: + raise AuthenticateException( + f"Failed to create SSO session: HTTP {response.status_code}" + ) + + access_token = response.headers.get(AUTHENTICATION_HEADER, "") + # Extract cookie from Set-Cookie header + refresh_cookie = "" + set_cookie = response.headers.get("Set-Cookie", "") + if set_cookie: + # Parse refreshId cookie from Set-Cookie header + for part in set_cookie.split(";"): + part = part.strip() + if part.startswith(f"{COOKIE_NAME}="): + refresh_cookie = part[len(f"{COOKIE_NAME}=") :] + break + + self.debug_fn(f"SSO login successful: access_token={access_token[:20]}...") + return access_token, refresh_cookie diff --git a/auth/libraries/pip-auth/osauthlib/oidc_authenticator.py b/auth/libraries/pip-auth/osauthlib/oidc_authenticator.py new file mode 100644 index 00000000..a15dc1d4 --- /dev/null +++ b/auth/libraries/pip-auth/osauthlib/oidc_authenticator.py @@ -0,0 +1,265 @@ +""" +OIDC Authenticator for per-request authentication. + +Validates Keycloak/OIDC access tokens and looks up the corresponding OpenSlides user. +""" + +from typing import Any, Callable, Dict, Optional, Tuple + +import jwt +from jwt import PyJWKClient + +from .exceptions import AuthenticateException, InvalidCredentialsException + + +class OidcAuthenticator: + """ + OIDC Token Authenticator for RS256 signed tokens. + + Validates tokens from Keycloak/OIDC providers using JWKS and looks up + the OpenSlides user by keycloak_id. + """ + + OIDC_USER_ID_CLAIM = "openslides_user_id" + + def __init__( + self, + issuer: str, + audience: str, + jwks_uri: Optional[str] = None, + debug_fn: Any = print, + ): + """ + Initialize the OIDC authenticator. + + Args: + issuer: The token issuer URL (Keycloak realm URL) + audience: The expected audience (client_id) + jwks_uri: The JWKS endpoint URL (defaults to issuer + + /protocol/openid-connect/certs) + debug_fn: Debug logging function + """ + self.issuer = issuer + self.audience = audience + self.jwks_uri = jwks_uri or f"{issuer}/protocol/openid-connect/certs" + self.debug_fn = debug_fn + self._jwks_client: Optional[PyJWKClient] = None + + @property + def jwks_client(self) -> PyJWKClient: + """Lazy-load JWKS client.""" + if self._jwks_client is None: + self._jwks_client = PyJWKClient(self.jwks_uri) + return self._jwks_client + + def is_oidc_token(self, token: str) -> bool: + """ + Check if the token looks like an OIDC JWT token. + + OIDC tokens are typically RS256 signed JWTs with a specific issuer. + OpenSlides tokens are HS256 signed. + + Args: + token: The token string (without 'bearer ' prefix) + + Returns: + True if this appears to be an OIDC token + """ + try: + # Try to get the header without verification + header = jwt.get_unverified_header(token) + # OIDC tokens use RS256, OpenSlides tokens use HS256 + return header.get("alg") == "RS256" + except jwt.exceptions.DecodeError: + return False + except Exception: + return False + + def validate_token(self, token: str) -> Dict[str, Any]: + """ + Validate an OIDC access token and return the decoded payload. + + Args: + token: The JWT token string (without 'bearer ' prefix) + + Returns: + Decoded token payload + + Raises: + InvalidCredentialsException: If token validation fails + AuthenticateException: If there's a system error (e.g., JWKS fetch) + """ + self.debug_fn("OidcAuthenticator.validate_token") + + try: + signing_key = self.jwks_client.get_signing_key_from_jwt(token) + # Disable PyJWT's audience check because Keycloak access tokens + # may not include an `aud` claim (they use `azp` instead). + # We verify the audience/azp manually below. + payload = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + options={"verify_aud": False}, + issuer=self.issuer, + ) + + # Manual audience verification: check `azp` first, then `aud`. + # Keycloak access tokens set `azp` (authorized party) to the + # requesting client and may set `aud` to other clients (e.g. + # "account") that have role mappings, so `azp` is the reliable + # indicator of the intended audience. + token_azp = payload.get("azp") + token_aud = payload.get("aud") + if token_azp: + if token_azp != self.audience: + raise InvalidCredentialsException("Invalid token audience") + elif token_aud: + aud_list = [token_aud] if isinstance(token_aud, str) else token_aud + if self.audience not in aud_list: + raise InvalidCredentialsException("Invalid token audience") + + return payload + except jwt.exceptions.InvalidSignatureError: + raise InvalidCredentialsException("Invalid token signature") + except jwt.exceptions.ExpiredSignatureError: + raise InvalidCredentialsException("Token has expired") + except jwt.exceptions.InvalidIssuerError: + raise InvalidCredentialsException("Invalid token issuer") + except jwt.exceptions.DecodeError as e: + raise InvalidCredentialsException(f"Token decode error: {e}") + except jwt.exceptions.PyJWKClientError as e: + raise AuthenticateException(f"JWKS fetch error: {e}") + except Exception as e: + raise InvalidCredentialsException(f"Token validation failed: {e}") + + def extract_user_id(self, token: str) -> int: + """ + Validate token and extract the OpenSlides user ID. + + The token must contain an 'openslides_user_id' claim that was added + by Keycloak via a protocol mapper. + + Args: + token: The JWT access token + + Returns: + OpenSlides user ID from the token + + Raises: + InvalidCredentialsException: If token is invalid + AuthenticateException: If user ID claim is missing or invalid + """ + payload = self.validate_token(token) + + user_id = payload.get(self.OIDC_USER_ID_CLAIM) + + if user_id is None: + raise AuthenticateException( + f"Missing {self.OIDC_USER_ID_CLAIM} claim in token. " + "Ensure Keycloak has a protocol mapper for OpenSlides user ID." + ) + + if not isinstance(user_id, int): + raise AuthenticateException( + f"{self.OIDC_USER_ID_CLAIM} must be an integer, " + f"got {type(user_id).__name__}" + ) + + return user_id + + def extract_keycloak_id(self, token: str) -> str: + """ + Validate token and extract the Keycloak user ID (sub claim). + + Args: + token: The JWT access token + + Returns: + The 'sub' claim from the token (Keycloak user UUID) + + Raises: + InvalidCredentialsException: If token is invalid + AuthenticateException: If sub claim is missing + """ + payload = self.validate_token(token) + + keycloak_id = payload.get("sub") + if not keycloak_id: + raise AuthenticateException("Missing 'sub' claim in token") + + if not isinstance(keycloak_id, str): + raise AuthenticateException( + f"'sub' claim must be a string, got {type(keycloak_id).__name__}" + ) + + return keycloak_id + + def authenticate(self, token: str) -> Tuple[int, Dict[str, Any]]: + """ + Validate token and return user ID and token payload. + + This method is for use with tokens that contain the openslides_user_id claim. + + Args: + token: The JWT access token + + Returns: + Tuple of (user_id, token_payload) + + Raises: + InvalidCredentialsException: If token is invalid + AuthenticateException: If user ID claim is missing or invalid + """ + payload = self.validate_token(token) + user_id = self.extract_user_id(token) + return user_id, payload + + def resolve_user_by_keycloak_id( + self, + token: str, + user_lookup_fn: "Callable[[str], Optional[Dict[str, Any]]]", + ) -> int: + """ + Validate token, extract keycloak_id (sub claim), and lookup user. + + This method validates the OIDC token, extracts the 'sub' claim as the + keycloak_id, and uses the provided lookup function to find the + corresponding OpenSlides user. + + Args: + token: The JWT access token + user_lookup_fn: Function that takes a keycloak_id string and returns + a user dict with at least 'id' and optionally 'is_active', + or None if user not found. + + Returns: + OpenSlides user ID + + Raises: + InvalidCredentialsException: If token is invalid + AuthenticateException: If user not found or user is deactivated + """ + self.debug_fn("OidcAuthenticator.resolve_user_by_keycloak_id") + + # Validate token and extract keycloak_id (sub claim) + keycloak_id = self.extract_keycloak_id(token) + self.debug_fn(f"Looking up user with keycloak_id: {keycloak_id}") + + # Lookup user by keycloak_id + user = user_lookup_fn(keycloak_id) + if user is None: + raise AuthenticateException( + f"No user found with keycloak_id: {keycloak_id}" + ) + + # Check if user is active + if not user.get("is_active", True): + raise AuthenticateException("User is deactivated") + + user_id = user.get("id") + if user_id is None: + raise AuthenticateException("User lookup returned user without id") + + self.debug_fn(f"Resolved keycloak_id {keycloak_id} to user_id {user_id}") + return user_id diff --git a/auth/libraries/pip-auth/requirements.txt b/auth/libraries/pip-auth/requirements.txt index fd25ee05..f392148d 100644 --- a/auth/libraries/pip-auth/requirements.txt +++ b/auth/libraries/pip-auth/requirements.txt @@ -1,4 +1,5 @@ argon2-cffi==25.1.0 +cryptography==44.0.3 ordered-set==4.1.0 pyjwt==2.11.0 redis==7.2.0 diff --git a/auth/libraries/pip-auth/tests/oidc_fixtures.py b/auth/libraries/pip-auth/tests/oidc_fixtures.py new file mode 100644 index 00000000..082a1125 --- /dev/null +++ b/auth/libraries/pip-auth/tests/oidc_fixtures.py @@ -0,0 +1,258 @@ +""" +OIDC Test Fixtures + +Provides RSA keypair generation and mock JWT token creation for OIDC/Keycloak testing. +""" + +import json +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +import jwt +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + + +class OIDCKeyPair: + """Generates and manages RSA keypair for OIDC token signing.""" + + def __init__(self, key_id: str = "test-key-1"): + self.key_id = key_id + self._private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + self._public_key = self._private_key.public_key() + + @property + def private_key(self) -> rsa.RSAPrivateKey: + return self._private_key + + @property + def public_key(self) -> rsa.RSAPublicKey: + return self._public_key + + def get_private_key_pem(self) -> bytes: + """Get private key in PEM format.""" + return self._private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + def get_public_key_pem(self) -> bytes: + """Get public key in PEM format.""" + return self._public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def get_jwk(self) -> Dict[str, Any]: + """Get public key as JWK (JSON Web Key).""" + public_numbers = self._public_key.public_numbers() + + def int_to_base64url(n: int, length: Optional[int] = None) -> str: + """Convert integer to base64url-encoded string.""" + import base64 + + byte_length = length or (n.bit_length() + 7) // 8 + data = n.to_bytes(byte_length, byteorder="big") + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + return { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": self.key_id, + "n": int_to_base64url(public_numbers.n), + "e": int_to_base64url(public_numbers.e), + } + + +class OIDCTokenFactory: + """Creates OIDC/JWT tokens for testing.""" + + DEFAULT_ISSUER = "http://localhost:8080/realms/openslides" + DEFAULT_AUDIENCE = "openslides" + + def __init__( + self, + keypair: OIDCKeyPair, + issuer: str = DEFAULT_ISSUER, + audience: str = DEFAULT_AUDIENCE, + ): + self.keypair = keypair + self.issuer = issuer + self.audience = audience + + def create_access_token( + self, + openslides_user_id: int, + subject: str = "test-user-uuid", + email: str = "testuser@example.com", + username: str = "testuser", + expires_in: int = 300, + extra_claims: Optional[Dict[str, Any]] = None, + ) -> str: + """ + Create a mock OIDC access token. + + Args: + openslides_user_id: The OpenSlides user ID mapped from Keycloak + subject: The Keycloak user UUID (sub claim) + email: User email + username: Keycloak username + expires_in: Token expiry in seconds + extra_claims: Additional claims to include + + Returns: + Encoded JWT token string + """ + now = datetime.now(timezone.utc) + payload = { + "exp": now + timedelta(seconds=expires_in), + "iat": now, + "nbf": now, + "jti": f"token-{int(time.time())}", + "iss": self.issuer, + "aud": self.audience, + "sub": subject, + "typ": "Bearer", + "azp": self.audience, + "scope": "openid email profile", + "email_verified": True, + "name": username, + "preferred_username": username, + "email": email, + "openslides_user_id": openslides_user_id, + } + + if extra_claims: + payload.update(extra_claims) + + return jwt.encode( + payload, + self.keypair.get_private_key_pem(), + algorithm="RS256", + headers={"kid": self.keypair.key_id}, + ) + + def create_expired_token( + self, + openslides_user_id: int, + **kwargs: Any, + ) -> str: + """Create a token that has already expired.""" + now = datetime.now(timezone.utc) + expired_claims = { + "exp": now - timedelta(hours=1), + "iat": now - timedelta(hours=2), + "nbf": now - timedelta(hours=2), + } + return self.create_access_token( + openslides_user_id, + extra_claims=expired_claims, + **kwargs, + ) + + def create_token_with_wrong_issuer( + self, + openslides_user_id: int, + wrong_issuer: str = "http://evil.com/realms/fake", + **kwargs: Any, + ) -> str: + """Create a token with incorrect issuer.""" + return self.create_access_token( + openslides_user_id, + extra_claims={"iss": wrong_issuer}, + **kwargs, + ) + + def create_token_without_user_id( + self, + subject: str = "test-user-uuid", + email: str = "testuser@example.com", + **kwargs: Any, + ) -> str: + """Create a token missing the openslides_user_id claim.""" + now = datetime.now(timezone.utc) + payload = { + "exp": now + timedelta(seconds=300), + "iat": now, + "nbf": now, + "iss": self.issuer, + "aud": self.audience, + "sub": subject, + "email": email, + } + return jwt.encode( + payload, + self.keypair.get_private_key_pem(), + algorithm="RS256", + headers={"kid": self.keypair.key_id}, + ) + + def create_token_with_invalid_user_id( + self, + invalid_user_id: Any = "not-an-integer", + **kwargs: Any, + ) -> str: + """Create a token with an invalid openslides_user_id type.""" + return self.create_access_token( + openslides_user_id=1, + extra_claims={"openslides_user_id": invalid_user_id}, + **kwargs, + ) + + +class MockJWKSServer: + """Mock JWKS endpoint response.""" + + def __init__(self, keypairs: Optional[List[OIDCKeyPair]] = None): + self.keypairs = keypairs or [] + + def add_keypair(self, keypair: OIDCKeyPair) -> None: + self.keypairs.append(keypair) + + def get_jwks_response(self) -> Dict[str, Any]: + """Get JWKS response containing all public keys.""" + return {"keys": [kp.get_jwk() for kp in self.keypairs]} + + def get_jwks_json(self) -> str: + """Get JWKS response as JSON string.""" + return json.dumps(self.get_jwks_response()) + + +def create_test_oidc_environment( + user_id: int = 1, + issuer: str = OIDCTokenFactory.DEFAULT_ISSUER, +) -> Tuple[OIDCKeyPair, OIDCTokenFactory, MockJWKSServer]: + """ + Create a complete OIDC test environment. + + Returns: + Tuple of (keypair, token_factory, jwks_server) + """ + keypair = OIDCKeyPair() + token_factory = OIDCTokenFactory(keypair, issuer=issuer) + jwks_server = MockJWKSServer([keypair]) + return keypair, token_factory, jwks_server + + +def create_invalid_signature_token( + token_factory: OIDCTokenFactory, + openslides_user_id: int = 1, +) -> str: + """ + Create a token signed with a different key (invalid signature). + """ + different_keypair = OIDCKeyPair(key_id="different-key") + different_factory = OIDCTokenFactory( + different_keypair, + issuer=token_factory.issuer, + audience=token_factory.audience, + ) + return different_factory.create_access_token(openslides_user_id) diff --git a/auth/libraries/pip-auth/tests/test_oidc_validator.py b/auth/libraries/pip-auth/tests/test_oidc_validator.py new file mode 100644 index 00000000..65468908 --- /dev/null +++ b/auth/libraries/pip-auth/tests/test_oidc_validator.py @@ -0,0 +1,548 @@ +""" +OIDC Token Validation Tests + +Tests for RS256 token validation and JWKS integration for OIDC/Keycloak authentication. +""" + +import json +import unittest +from typing import Any, Dict, Optional +from unittest.mock import MagicMock + +import jwt + +from osauthlib.exceptions import AuthenticateException, InvalidCredentialsException + +from .oidc_fixtures import ( + MockJWKSServer, + OIDCKeyPair, + OIDCTokenFactory, + create_invalid_signature_token, + create_test_oidc_environment, +) + + +class OIDCValidator: + """ + OIDC Token Validator for RS256 signed tokens. + + This validator fetches the JWKS from the identity provider and validates + tokens signed with RS256 algorithm. + """ + + OIDC_USER_ID_CLAIM = "openslides_user_id" + + def __init__( + self, + issuer: str, + audience: str, + jwks_uri: str, + debug_fn: Any = print, + ): + self.issuer = issuer + self.audience = audience + self.jwks_uri = jwks_uri + self.debug_fn = debug_fn + self._jwks_client: Optional[jwt.PyJWKClient] = None + + @property + def jwks_client(self) -> jwt.PyJWKClient: + """Lazy-load JWKS client.""" + if self._jwks_client is None: + self._jwks_client = jwt.PyJWKClient(self.jwks_uri) + return self._jwks_client + + def validate_token(self, token: str) -> Dict[str, Any]: + """ + Validate an OIDC token and return the decoded payload. + + Args: + token: The JWT token string (without 'bearer ' prefix) + + Returns: + Decoded token payload + + Raises: + InvalidCredentialsException: If token validation fails + """ + self.debug_fn("OIDCValidator.validate_token") + + try: + signing_key = self.jwks_client.get_signing_key_from_jwt(token) + payload = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + options={"verify_aud": False}, + issuer=self.issuer, + ) + + # Manual audience verification: check `azp` first, then `aud`. + token_azp = payload.get("azp") + token_aud = payload.get("aud") + if token_azp: + if token_azp != self.audience: + raise InvalidCredentialsException("Invalid token audience") + elif token_aud: + aud_list = [token_aud] if isinstance(token_aud, str) else token_aud + if self.audience not in aud_list: + raise InvalidCredentialsException("Invalid token audience") + + return payload + except jwt.exceptions.InvalidSignatureError: + raise InvalidCredentialsException("Invalid token signature") + except jwt.exceptions.ExpiredSignatureError: + raise InvalidCredentialsException("Token has expired") + except jwt.exceptions.InvalidIssuerError: + raise InvalidCredentialsException("Invalid token issuer") + except jwt.exceptions.DecodeError as e: + raise InvalidCredentialsException(f"Token decode error: {e}") + except jwt.exceptions.PyJWKClientError as e: + raise AuthenticateException(f"JWKS fetch error: {e}") + except Exception as e: + raise InvalidCredentialsException(f"Token validation failed: {e}") + + def extract_user_id(self, token: str) -> int: + """ + Validate token and extract the OpenSlides user ID. + + Args: + token: The JWT token string + + Returns: + OpenSlides user ID from the token + + Raises: + InvalidCredentialsException: If token is invalid or missing user ID + AuthenticateException: If user ID claim is missing or invalid + """ + payload = self.validate_token(token) + + user_id = payload.get(self.OIDC_USER_ID_CLAIM) + + if user_id is None: + raise AuthenticateException( + f"Missing {self.OIDC_USER_ID_CLAIM} claim in token" + ) + + if not isinstance(user_id, int): + raise AuthenticateException( + f"{self.OIDC_USER_ID_CLAIM} must be an integer, " + f"got {type(user_id).__name__}" + ) + + return user_id + + +class TestOIDCValidator(unittest.TestCase): + """Tests for OIDC token validation.""" + + keypair: OIDCKeyPair + token_factory: OIDCTokenFactory + jwks_server: MockJWKSServer + issuer: str + audience: str + + @classmethod + def setUpClass(cls): # type: ignore[override] + """Set up test OIDC environment.""" + cls.keypair, cls.token_factory, cls.jwks_server = create_test_oidc_environment() + cls.issuer = cls.token_factory.issuer + cls.audience = cls.token_factory.audience + + def _create_validator_with_mock_jwks(self) -> OIDCValidator: + """Create an OIDCValidator with mocked JWKS client.""" + validator = OIDCValidator( + issuer=self.issuer, + audience=self.audience, + jwks_uri=( + "http://localhost:8080/realms/openslides" + "/protocol/openid-connect/certs" + ), + debug_fn=lambda x: None, + ) + + mock_jwks_client = MagicMock(spec=jwt.PyJWKClient) + + def get_signing_key_from_jwt(token): + jwk = self.keypair.get_jwk() + return jwt.PyJWK.from_dict(jwk) + + mock_jwks_client.get_signing_key_from_jwt = get_signing_key_from_jwt + validator._jwks_client = mock_jwks_client # type: ignore[assignment] + + return validator + + def test_validate_valid_token(self): + """Test validating a properly signed RS256 token.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_access_token( + openslides_user_id=1, + email="admin@example.com", + username="admin", + ) + + payload = validator.validate_token(token) + + self.assertEqual(payload["openslides_user_id"], 1) + self.assertEqual(payload["email"], "admin@example.com") + self.assertEqual(payload["preferred_username"], "admin") + self.assertEqual(payload["iss"], self.issuer) + self.assertEqual(payload["aud"], self.audience) + + def test_extract_user_id_success(self): + """Test extracting user ID from valid token.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_access_token(openslides_user_id=42) + + user_id = validator.extract_user_id(token) + + self.assertEqual(user_id, 42) + + def test_extract_user_id_for_admin(self): + """Test extracting user ID for admin user (user_id=1).""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_access_token( + openslides_user_id=1, + username="admin", + ) + + user_id = validator.extract_user_id(token) + + self.assertEqual(user_id, 1) + + def test_extract_user_id_for_testuser(self): + """Test extracting user ID for test user (user_id=2).""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_access_token( + openslides_user_id=2, + username="testuser", + email="testuser@example.com", + ) + + user_id = validator.extract_user_id(token) + + self.assertEqual(user_id, 2) + + def test_reject_invalid_signature(self): + """Test rejection of token with invalid signature.""" + validator = self._create_validator_with_mock_jwks() + token = create_invalid_signature_token(self.token_factory, openslides_user_id=1) + + with self.assertRaises(InvalidCredentialsException) as context: + validator.validate_token(token) + + self.assertIn("signature", context.exception.message.lower()) + + def test_reject_expired_token(self): + """Test rejection of expired token.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_expired_token(openslides_user_id=1) + + with self.assertRaises(InvalidCredentialsException) as context: + validator.validate_token(token) + + self.assertIn("expired", context.exception.message.lower()) + + def test_reject_wrong_issuer(self): + """Test rejection of token with wrong issuer.""" + validator = self._create_validator_with_mock_jwks() + + wrong_issuer_keypair = OIDCKeyPair() + wrong_issuer_factory = OIDCTokenFactory( + wrong_issuer_keypair, + issuer="http://evil.example.com/realms/fake", + audience=self.audience, + ) + + MockJWKSServer([wrong_issuer_keypair]) # noqa: F841 + + def get_signing_key_from_wrong_issuer(token): + jwk = wrong_issuer_keypair.get_jwk() + return jwt.PyJWK.from_dict(jwk) + + client = validator._jwks_client + assert client is not None + client.get_signing_key_from_jwt = ( # type: ignore[method-assign] + get_signing_key_from_wrong_issuer + ) + + token = wrong_issuer_factory.create_access_token(openslides_user_id=1) + + with self.assertRaises(InvalidCredentialsException) as context: + validator.validate_token(token) + + self.assertIn("issuer", context.exception.message.lower()) + + def test_reject_wrong_audience(self): + """Test rejection of token with wrong audience.""" + validator = self._create_validator_with_mock_jwks() + + wrong_audience_factory = OIDCTokenFactory( + self.keypair, + issuer=self.issuer, + audience="wrong-client-id", + ) + + token = wrong_audience_factory.create_access_token(openslides_user_id=1) + + with self.assertRaises(InvalidCredentialsException) as context: + validator.validate_token(token) + + self.assertIn("audience", context.exception.message.lower()) + + def test_reject_missing_user_id_claim(self): + """Test rejection of token missing openslides_user_id claim.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_token_without_user_id() + + with self.assertRaises(AuthenticateException) as context: + validator.extract_user_id(token) + + self.assertIn("openslides_user_id", context.exception.message) + + def test_reject_invalid_user_id_type_string(self): + """Test rejection of token with string user_id instead of int.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_token_with_invalid_user_id( + invalid_user_id="not-an-integer" + ) + + with self.assertRaises(AuthenticateException) as context: + validator.extract_user_id(token) + + self.assertIn("integer", context.exception.message.lower()) + + def test_reject_invalid_user_id_type_float(self): + """Test rejection of token with float user_id instead of int.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_token_with_invalid_user_id( + invalid_user_id=1.5 + ) + + with self.assertRaises(AuthenticateException) as context: + validator.extract_user_id(token) + + self.assertIn("integer", context.exception.message.lower()) + + def test_reject_invalid_user_id_type_null(self): + """Test rejection of token with null user_id.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_token_with_invalid_user_id( + invalid_user_id=None + ) + + with self.assertRaises(AuthenticateException) as context: + validator.extract_user_id(token) + + self.assertIn("openslides_user_id", context.exception.message) + + def test_handle_jwks_endpoint_error(self): + """Test handling of JWKS endpoint connection failure.""" + validator = OIDCValidator( + issuer=self.issuer, + audience=self.audience, + jwks_uri=( + "http://localhost:8080/realms/openslides" + "/protocol/openid-connect/certs" + ), + debug_fn=lambda x: None, + ) + + mock_jwks_client = MagicMock(spec=jwt.PyJWKClient) + mock_jwks_client.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError( + "Connection refused" + ) + validator._jwks_client = mock_jwks_client + + token = self.token_factory.create_access_token(openslides_user_id=1) + + with self.assertRaises(AuthenticateException) as context: + validator.validate_token(token) + + self.assertIn("JWKS", context.exception.message) + + def test_handle_malformed_token(self): + """Test handling of malformed JWT token.""" + validator = self._create_validator_with_mock_jwks() + + with self.assertRaises(InvalidCredentialsException): + validator.validate_token("not.a.valid.token") + + def test_handle_empty_token(self): + """Test handling of empty token.""" + validator = self._create_validator_with_mock_jwks() + + with self.assertRaises(InvalidCredentialsException): + validator.validate_token("") + + def test_token_contains_required_claims(self): + """Test that created tokens contain all required OIDC claims.""" + validator = self._create_validator_with_mock_jwks() + token = self.token_factory.create_access_token( + openslides_user_id=1, + subject="user-uuid-123", + email="test@example.com", + username="testuser", + ) + + payload = validator.validate_token(token) + + required_claims = [ + "iss", + "aud", + "sub", + "exp", + "iat", + "email", + "openslides_user_id", + ] + for claim in required_claims: + self.assertIn(claim, payload, f"Missing required claim: {claim}") + + +class TestOIDCKeyPair(unittest.TestCase): + """Tests for RSA keypair generation.""" + + def test_keypair_generation(self): + """Test that keypair is properly generated.""" + keypair = OIDCKeyPair() + + self.assertIsNotNone(keypair.private_key) + self.assertIsNotNone(keypair.public_key) + + def test_private_key_pem_format(self): + """Test private key PEM format.""" + keypair = OIDCKeyPair() + pem = keypair.get_private_key_pem() + + self.assertTrue(pem.startswith(b"-----BEGIN PRIVATE KEY-----")) + self.assertTrue(pem.strip().endswith(b"-----END PRIVATE KEY-----")) + + def test_public_key_pem_format(self): + """Test public key PEM format.""" + keypair = OIDCKeyPair() + pem = keypair.get_public_key_pem() + + self.assertTrue(pem.startswith(b"-----BEGIN PUBLIC KEY-----")) + self.assertTrue(pem.strip().endswith(b"-----END PUBLIC KEY-----")) + + def test_jwk_format(self): + """Test JWK contains required fields.""" + keypair = OIDCKeyPair(key_id="my-test-key") + jwk = keypair.get_jwk() + + self.assertEqual(jwk["kty"], "RSA") + self.assertEqual(jwk["use"], "sig") + self.assertEqual(jwk["alg"], "RS256") + self.assertEqual(jwk["kid"], "my-test-key") + self.assertIn("n", jwk) + self.assertIn("e", jwk) + + +class TestMockJWKSServer(unittest.TestCase): + """Tests for mock JWKS server.""" + + def test_jwks_response_format(self): + """Test JWKS response has correct format.""" + keypair = OIDCKeyPair() + jwks_server = MockJWKSServer([keypair]) + + response = jwks_server.get_jwks_response() + + self.assertIn("keys", response) + self.assertEqual(len(response["keys"]), 1) + self.assertEqual(response["keys"][0]["kty"], "RSA") + + def test_multiple_keys(self): + """Test JWKS with multiple keys.""" + keypair1 = OIDCKeyPair(key_id="key-1") + keypair2 = OIDCKeyPair(key_id="key-2") + jwks_server = MockJWKSServer() + jwks_server.add_keypair(keypair1) + jwks_server.add_keypair(keypair2) + + response = jwks_server.get_jwks_response() + + self.assertEqual(len(response["keys"]), 2) + key_ids = [k["kid"] for k in response["keys"]] + self.assertIn("key-1", key_ids) + self.assertIn("key-2", key_ids) + + def test_json_output(self): + """Test JWKS JSON serialization.""" + keypair = OIDCKeyPair() + jwks_server = MockJWKSServer([keypair]) + + json_str = jwks_server.get_jwks_json() + parsed = json.loads(json_str) + + self.assertIn("keys", parsed) + + +class TestOIDCTokenFactory(unittest.TestCase): + """Tests for OIDC token creation.""" + + def setUp(self): + self.keypair = OIDCKeyPair() + self.factory = OIDCTokenFactory(self.keypair) + + def test_create_valid_token(self): + """Test creating a valid token.""" + token = self.factory.create_access_token( + openslides_user_id=1, + email="test@example.com", + username="testuser", + ) + + decoded = jwt.decode( + token, + self.keypair.get_public_key_pem(), + algorithms=["RS256"], + audience=OIDCTokenFactory.DEFAULT_AUDIENCE, + issuer=OIDCTokenFactory.DEFAULT_ISSUER, + ) + + self.assertEqual(decoded["openslides_user_id"], 1) + self.assertEqual(decoded["email"], "test@example.com") + self.assertEqual(decoded["preferred_username"], "testuser") + + def test_token_header_contains_kid(self): + """Test token header contains key ID.""" + token = self.factory.create_access_token(openslides_user_id=1) + + header = jwt.get_unverified_header(token) + + self.assertEqual(header["kid"], self.keypair.key_id) + self.assertEqual(header["alg"], "RS256") + + def test_create_expired_token(self): + """Test creating an already-expired token.""" + token = self.factory.create_expired_token(openslides_user_id=1) + + with self.assertRaises(jwt.ExpiredSignatureError): + jwt.decode( + token, + self.keypair.get_public_key_pem(), + algorithms=["RS256"], + audience=OIDCTokenFactory.DEFAULT_AUDIENCE, + issuer=OIDCTokenFactory.DEFAULT_ISSUER, + ) + + def test_create_token_without_user_id(self): + """Test creating token without user ID claim.""" + token = self.factory.create_token_without_user_id() + + decoded = jwt.decode( + token, + self.keypair.get_public_key_pem(), + algorithms=["RS256"], + audience=OIDCTokenFactory.DEFAULT_AUDIENCE, + issuer=OIDCTokenFactory.DEFAULT_ISSUER, + ) + + self.assertNotIn("openslides_user_id", decoded) + + +if __name__ == "__main__": + unittest.main() diff --git a/auth/src/api/services/user-service.ts b/auth/src/api/services/user-service.ts index f33700c6..0a0ad08f 100644 --- a/auth/src/api/services/user-service.ts +++ b/auth/src/api/services/user-service.ts @@ -10,7 +10,7 @@ import { Database, EventType, GetManyAnswer } from '../interfaces/database'; import { HashingHandler } from '../interfaces/hashing-handler'; import { UserHandler } from '../interfaces/user-handler'; -const userFields: (keyof User)[] = ['id', 'username', 'password', 'is_active', 'saml_id']; +const userFields: (keyof User)[] = ['id', 'username', 'password', 'is_active', 'saml_id', 'keycloak_id']; const dummyPassword = '$argon2id$v=19$m=65536,t=3,p=4$IGvN2jGNrF5aPB5G85671w$zdaAc/BrqhD7edEz5bJroJ+M9xeZrUWao34lY8494cM'; diff --git a/auth/src/core/models/user.ts b/auth/src/core/models/user.ts index c36ab619..e7faa203 100644 --- a/auth/src/core/models/user.ts +++ b/auth/src/core/models/user.ts @@ -7,6 +7,7 @@ export class User extends BaseModel { public readonly id: Id; public readonly saml_id: string; + public readonly keycloak_id: string; public readonly username: string; public readonly password: string; public readonly email: string; diff --git a/auth/src/express/controllers/private-controller.ts b/auth/src/express/controllers/private-controller.ts index 50fd8426..0d09b909 100644 --- a/auth/src/express/controllers/private-controller.ts +++ b/auth/src/express/controllers/private-controller.ts @@ -60,4 +60,17 @@ export class PrivateController { await this._authHandler.clearAllSessions(userId); return createResponse(); } + + /** + * SSO login endpoint for OIDC authentication. + * Creates a session for a user by ID (similar to SAML login flow). + * Returns access token in authentication header and refresh cookie. + */ + @OnPost('sso-login') + public async ssoLogin(@Body('userId') userId: Id, @Res() res: Response): Promise { + const ticket = await this._authHandler.doSamlLogin(userId); + res.setHeader(AuthHandler.AUTHENTICATION_HEADER, ticket.token.toString()); + res.cookie(AuthHandler.COOKIE_NAME, ticket.cookie.toString(), { secure: true, httpOnly: true }); + return createResponse(); + } } diff --git a/init.sql b/init.sql index c4635eea..e1e753b7 100644 --- a/init.sql +++ b/init.sql @@ -12,6 +12,7 @@ CREATE TABLE user_t ( username varchar(256) NOT NULL, member_number varchar(256), saml_id varchar(256) CONSTRAINT minlength_saml_id CHECK (char_length(saml_id) >= 1), + keycloak_id varchar(256) CONSTRAINT minlength_keycloak_id CHECK (char_length(keycloak_id) >= 1), pronoun varchar(32), title varchar(256), first_name varchar(256), diff --git a/keycloak/Dockerfile b/keycloak/Dockerfile new file mode 100644 index 00000000..cf60f273 --- /dev/null +++ b/keycloak/Dockerfile @@ -0,0 +1,2 @@ +FROM quay.io/keycloak/keycloak:26.2 +COPY themes /opt/keycloak/themes diff --git a/keycloak/themes/openslides/login/error.ftl b/keycloak/themes/openslides/login/error.ftl new file mode 100644 index 00000000..7decf360 --- /dev/null +++ b/keycloak/themes/openslides/login/error.ftl @@ -0,0 +1,17 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=false ; section> + <#if section = "form"> +

${msg("errorTitle")}

+ <#if message?has_content> +
+ ${kcSanitize(message.summary)?no_esc} +
+ + <#if skipLink??> + <#else> + <#if client?? && client.baseUrl?has_content> + ${kcSanitize(msg("backToApplication"))?no_esc} + + + + diff --git a/keycloak/themes/openslides/login/info.ftl b/keycloak/themes/openslides/login/info.ftl new file mode 100644 index 00000000..dda9c359 --- /dev/null +++ b/keycloak/themes/openslides/login/info.ftl @@ -0,0 +1,36 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=false ; section> + <#if section = "form"> + <#if messageHeader??> +

${kcSanitize(messageHeader)?no_esc}

+ <#else> +

${message.summary}

+ + + <#if message?has_content && message.type != 'warning'> +
+ ${kcSanitize(message.summary)?no_esc} +
+ + + <#if requiredActions??> +

+ <#list requiredActions as reqAction> + ${kcSanitize(msg("requiredAction.${reqAction}"))?no_esc} + <#sep>, + +

+ + + <#if skipLink??> + <#else> + <#if pageRedirectUri?has_content> + ${kcSanitize(msg("backToApplication"))?no_esc} + <#elseif actionUri?has_content> + ${kcSanitize(msg("proceedWithAction"))?no_esc} + <#elseif (client.baseUrl)?has_content> + ${kcSanitize(msg("backToApplication"))?no_esc} + + + + diff --git a/keycloak/themes/openslides/login/login-reset-password.ftl b/keycloak/themes/openslides/login/login-reset-password.ftl new file mode 100644 index 00000000..e2bc0b45 --- /dev/null +++ b/keycloak/themes/openslides/login/login-reset-password.ftl @@ -0,0 +1,22 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true displayMessage=!messagesPerField.existsError('username') ; section> + <#if section = "form"> +

${msg("emailForgotTitle")}

+
+
+ + + <#if messagesPerField.existsError('username')> + ${kcSanitize(messagesPerField.getFirstError('username'))?no_esc} + +
+ +
+ « ${msg("backToLogin")} + <#elseif section = "info"> +

${msg("emailInstruction")}

+ + diff --git a/keycloak/themes/openslides/login/login-update-password.ftl b/keycloak/themes/openslides/login/login-update-password.ftl new file mode 100644 index 00000000..4f3c23ef --- /dev/null +++ b/keycloak/themes/openslides/login/login-update-password.ftl @@ -0,0 +1,33 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password','password-confirm') ; section> + <#if section = "form"> +

${msg("updatePasswordTitle")}

+
+ + + +
+ + + <#if messagesPerField.existsError('password')> + ${kcSanitize(messagesPerField.getFirstError('password'))?no_esc} + +
+ +
+ + + <#if messagesPerField.existsError('password-confirm')> + ${kcSanitize(messagesPerField.getFirstError('password-confirm'))?no_esc} + +
+ + <#if isAppInitiatedAction??> + + + <#else> + + +
+ + diff --git a/keycloak/themes/openslides/login/login.ftl b/keycloak/themes/openslides/login/login.ftl new file mode 100644 index 00000000..3753e0b4 --- /dev/null +++ b/keycloak/themes/openslides/login/login.ftl @@ -0,0 +1,58 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') ; section> + <#if section = "form"> +

${msg("loginAccountTitle")}

+
+
+ + + <#if messagesPerField.existsError('username')> + ${kcSanitize(messagesPerField.getFirstError('username'))?no_esc} + +
+
+ + + <#if messagesPerField.existsError('password')> + ${kcSanitize(messagesPerField.getFirstError('password'))?no_esc} + +
+ + <#if realm.rememberMe && !usernameHidden??> +
+ +
+ + + + + <#if realm.resetPasswordAllowed> + + +
+ + <#if realm.password && social.providers??> +
+
+ <#list social.providers as p> + + ${p.displayName!} + + +
+ + + <#if realm.registrationAllowed && !registrationDisabled??> +
+ ${msg("noAccount")} + ${msg("doRegister")} +
+ + + diff --git a/keycloak/themes/openslides/login/register.ftl b/keycloak/themes/openslides/login/register.ftl new file mode 100644 index 00000000..1e720dfe --- /dev/null +++ b/keycloak/themes/openslides/login/register.ftl @@ -0,0 +1,68 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm') ; section> + <#if section = "form"> +

${msg("registerTitle")}

+
+
+ + + <#if messagesPerField.existsError('firstName')> + ${kcSanitize(messagesPerField.getFirstError('firstName'))?no_esc} + +
+ +
+ + + <#if messagesPerField.existsError('lastName')> + ${kcSanitize(messagesPerField.getFirstError('lastName'))?no_esc} + +
+ +
+ + + <#if messagesPerField.existsError('email')> + ${kcSanitize(messagesPerField.getFirstError('email'))?no_esc} + +
+ + <#if !realm.registrationEmailAsUsername> +
+ + + <#if messagesPerField.existsError('username')> + ${kcSanitize(messagesPerField.getFirstError('username'))?no_esc} + +
+ + + <#if passwordRequired??> +
+ + + <#if messagesPerField.existsError('password')> + ${kcSanitize(messagesPerField.getFirstError('password'))?no_esc} + +
+ +
+ + + <#if messagesPerField.existsError('password-confirm')> + ${kcSanitize(messagesPerField.getFirstError('password-confirm'))?no_esc} + +
+ + + <#if recaptchaRequired??> +
+
+
+ + + +
+ « ${msg("backToLogin")} + + diff --git a/keycloak/themes/openslides/login/resources/css/openslides-login.css b/keycloak/themes/openslides/login/resources/css/openslides-login.css new file mode 100644 index 00000000..4518845e --- /dev/null +++ b/keycloak/themes/openslides/login/resources/css/openslides-login.css @@ -0,0 +1,349 @@ +/* OpenSlides Keycloak Login Theme + * Matches the OpenSlides client login page styling. + * CSS custom properties are updated dynamically by openslides-theme.js + */ + +:root { + /* Default primary palette (#317796) */ + --theme-primary-50: #e6eef2; + --theme-primary-100: #c0d5de; + --theme-primary-200: #98bbc8; + --theme-primary-300: #6fa0b3; + --theme-primary-400: #508ca4; + --theme-primary-500: #317796; + --theme-primary-600: #2a6175; + --theme-primary-700: #224a58; + --theme-primary-800: #173441; + --theme-primary-900: #0a1a22; + --theme-primary-a100: #b3e5fc; + --theme-primary-a200: #81d4fa; + --theme-primary-a400: #4fc3f7; + --theme-primary-a700: #29b6f6; + + /* Default accent palette (#2196f3) */ + --theme-accent-50: #e4f2fd; + --theme-accent-100: #bbdefb; + --theme-accent-200: #90caf9; + --theme-accent-300: #64b5f6; + --theme-accent-400: #42a5f5; + --theme-accent-500: #2196f3; + --theme-accent-600: #1565c0; + --theme-accent-700: #0d47a1; + --theme-accent-800: #082e6a; + --theme-accent-900: #041536; + --theme-accent-a100: #b3e5fc; + --theme-accent-a200: #81d4fa; + --theme-accent-a400: #4fc3f7; + --theme-accent-a700: #29b6f6; + + /* Default warn palette (#f06400) */ + --theme-warn-50: #fdeee4; + --theme-warn-100: #fbd4bb; + --theme-warn-200: #f8b890; + --theme-warn-300: #f49c64; + --theme-warn-400: #f28042; + --theme-warn-500: #f06400; + --theme-warn-600: #b94e00; + --theme-warn-700: #893a00; + --theme-warn-800: #582600; + --theme-warn-900: #2c1300; + --theme-warn-a100: #ffd180; + --theme-warn-a200: #ffab40; + --theme-warn-a400: #ff9100; + --theme-warn-a700: #ff6d00; + + /* Headbar color */ + --theme-headbar: #193c4b; + + /* Contrast colors */ + --theme-primary-500-contrast: #ffffff; + --theme-accent-500-contrast: #ffffff; + --theme-warn-500-contrast: #ffffff; +} + +/* ======================================== + * Hide the default Keycloak v2 chrome + * ======================================== */ + +/* Hide the default Keycloak header/footer */ +#kc-header-wrapper, +#kc-header { + display: none !important; +} + +/* ======================================== + * Page layout - replicates login-wrapper + * ======================================== */ + +html, body { + height: 100%; + margin: 0; + padding: 0; +} + +body { + font-family: Roboto, "Helvetica Neue", sans-serif; + background-color: #fafafa; +} + +/* Main wrapper: full-height flex column */ +.os-login-wrapper { + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; + display: flex; + flex-direction: column; + overflow: auto; + background-color: #fafafa; +} + +/* Logo bar */ +.os-logo-bar { + min-height: 80px; + max-height: 200px; + height: 20vh; + background-color: var(--theme-primary-500); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.os-logo-bar img { + width: 100%; + max-width: 400px; + display: block; + padding: 0 20px; + box-sizing: border-box; +} + +/* Content area */ +.os-content { + flex: 1 0 auto; + display: flex; + justify-content: center; +} + +.os-login-container { + padding-top: 50px; + margin: 0 auto; + max-width: 400px; + width: 100%; + box-sizing: border-box; + padding-left: 30px; + padding-right: 30px; +} + +/* Footer */ +.os-footer { + flex-shrink: 0; + margin-left: auto; + margin-right: auto; + font-size: 12px; + text-align: center; + padding: 16px 0; +} + +.os-footer a { + color: rgba(0, 0, 0, 0.6); + text-decoration: none; +} + +.os-footer a:hover { + text-decoration: underline; +} + +/* ======================================== + * Form styling - replicates login-mask + * ======================================== */ + +.os-login-container h1, +.os-login-container #kc-page-title { + font-size: 20px; + font-weight: 500; + text-align: center; + margin-bottom: 24px; + color: rgba(0, 0, 0, 0.87); +} + +/* Form group layout */ +.os-login-container .pf-v5-c-form__group, +.os-login-container .pf-c-form__group { + margin-bottom: 16px; +} + +/* Labels */ +.os-login-container label { + display: block; + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + margin-bottom: 4px; + font-weight: 400; +} + +/* Text inputs */ +.os-login-container input[type="text"], +.os-login-container input[type="password"], +.os-login-container input[type="email"] { + width: 100%; + padding: 8px 0; + border: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.42); + background: transparent; + font-size: 16px; + font-family: inherit; + color: rgba(0, 0, 0, 0.87); + outline: none; + box-sizing: border-box; + border-radius: 0; + -webkit-appearance: none; +} + +.os-login-container input[type="text"]:focus, +.os-login-container input[type="password"]:focus, +.os-login-container input[type="email"]:focus { + border-bottom: 2px solid var(--theme-primary-500); + padding-bottom: 7px; +} + +/* Login button */ +.os-login-container #kc-login, +.os-login-container input[type="submit"], +.os-login-container button[type="submit"] { + margin-top: 30px; + height: 37px; + width: 100%; + background-color: var(--theme-primary-500); + color: var(--theme-primary-500-contrast, #fff); + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + text-transform: uppercase; + cursor: pointer; + letter-spacing: 0.5px; + font-family: inherit; +} + +.os-login-container #kc-login:hover, +.os-login-container input[type="submit"]:hover, +.os-login-container button[type="submit"]:hover { + background-color: var(--theme-primary-600); +} + +.os-login-container #kc-login:active, +.os-login-container input[type="submit"]:active, +.os-login-container button[type="submit"]:active { + background-color: var(--theme-primary-700); +} + +/* Links */ +.os-login-container a { + color: var(--theme-primary-500); + text-decoration: none; + font-size: 14px; +} + +.os-login-container a:hover { + text-decoration: underline; +} + +/* Forgot password link */ +#kc-form-options { + text-align: right; + margin-top: 8px; +} + +/* Error / info messages */ +.os-alert { + padding: 12px 16px; + border-radius: 4px; + margin-bottom: 16px; + font-size: 14px; +} + +.os-alert-error { + background-color: #fdecea; + color: #611a15; + border: 1px solid #f5c6cb; +} + +.os-alert-warning { + background-color: #fff3e0; + color: #663c00; + border: 1px solid #ffe0b2; +} + +.os-alert-info { + background-color: #e3f2fd; + color: #0d47a1; + border: 1px solid #bbdefb; +} + +.os-alert-success { + background-color: #e8f5e9; + color: #1b5e20; + border: 1px solid #c8e6c9; +} + +/* Back link */ +.os-back-link { + display: block; + margin-top: 16px; + text-align: center; +} + +/* Checkbox styling */ +.os-login-container input[type="checkbox"] { + margin-right: 8px; +} + +/* Hide PatternFly / Keycloak default elements that we replace */ +.pf-v5-c-login, +.pf-c-login, +#kc-locale { + display: none !important; +} + +/* ======================================== + * Dark mode + * ======================================== */ + +@media (prefers-color-scheme: dark) { + body, + .os-login-wrapper { + background-color: #303030; + } + + .os-login-container h1, + .os-login-container #kc-page-title { + color: rgba(255, 255, 255, 0.87); + } + + .os-login-container label { + color: rgba(255, 255, 255, 0.6); + } + + .os-login-container input[type="text"], + .os-login-container input[type="password"], + .os-login-container input[type="email"] { + color: rgba(255, 255, 255, 0.87); + border-bottom-color: rgba(255, 255, 255, 0.42); + } + + .os-footer a { + color: rgba(255, 255, 255, 0.6); + } + + .os-alert-error { + background-color: #4e1a15; + color: #f5c6cb; + } + + .os-alert-info { + background-color: #0d2744; + color: #bbdefb; + } +} diff --git a/keycloak/themes/openslides/login/resources/img/openslides-logo-dark.svg b/keycloak/themes/openslides/login/resources/img/openslides-logo-dark.svg new file mode 100644 index 00000000..c1e6f527 --- /dev/null +++ b/keycloak/themes/openslides/login/resources/img/openslides-logo-dark.svg @@ -0,0 +1,644 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/keycloak/themes/openslides/login/resources/js/openslides-theme.js b/keycloak/themes/openslides/login/resources/js/openslides-theme.js new file mode 100644 index 00000000..b100cdd6 --- /dev/null +++ b/keycloak/themes/openslides/login/resources/js/openslides-theme.js @@ -0,0 +1,271 @@ +/** + * OpenSlides Keycloak Theme - Dynamic Color Loader + * + * Fetches the organization's active theme from /system/presenter/theme + * and applies colors as CSS custom properties. + * + * Palette generation algorithm ported from: + * openslides-client/client/src/app/site/services/color.service.ts + */ +(function () { + "use strict"; + + // --- Color utilities --- + + function hexToRgb(hex) { + hex = hex.replace(/^#/, ""); + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + return { + r: parseInt(hex.substring(0, 2), 16), + g: parseInt(hex.substring(2, 4), 16), + b: parseInt(hex.substring(4, 6), 16), + }; + } + + function rgbToHex(r, g, b) { + return ( + "#" + + [r, g, b] + .map(function (v) { + var s = Math.max(0, Math.min(255, Math.round(v))).toString(16); + return s.length === 1 ? "0" + s : s; + }) + .join("") + ); + } + + function mix(color1, color2, weight) { + // Mixes two RGB colors. weight is 0-100 (percentage of color2). + var w = weight / 100; + return { + r: color1.r + (color2.r - color1.r) * w, + g: color1.g + (color2.g - color1.g) * w, + b: color1.b + (color2.b - color1.b) * w, + }; + } + + function multiply(rgb1, rgb2) { + return { + r: Math.floor((rgb1.r * rgb2.r) / 255), + g: Math.floor((rgb1.g * rgb2.g) / 255), + b: Math.floor((rgb1.b * rgb2.b) / 255), + }; + } + + // Determine if a color is "light" (needs dark contrast text). + // Uses the W3C perceived brightness formula. + function isLight(rgb) { + var brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; + return brightness > 128; + } + + function saturate(rgb, amount) { + // Simple saturation adjustment + var max = Math.max(rgb.r, rgb.g, rgb.b) / 255; + var min = Math.min(rgb.r, rgb.g, rgb.b) / 255; + var l = (max + min) / 2; + var d = max - min; + var s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); + if (d === 0) return rgb; // achromatic + + // Compute hue + var h; + var rn = rgb.r / 255, + gn = rgb.g / 255, + bn = rgb.b / 255; + if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / d + 2) / 6; + else h = ((rn - gn) / d + 4) / 6; + + s = Math.min(1, s + amount / 100); + return hslToRgb(h, s, l); + } + + function lighten(rgb, amount) { + var max = Math.max(rgb.r, rgb.g, rgb.b) / 255; + var min = Math.min(rgb.r, rgb.g, rgb.b) / 255; + var l = (max + min) / 2; + var d = max - min; + var s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); + var h; + var rn = rgb.r / 255, + gn = rgb.g / 255, + bn = rgb.b / 255; + if (d === 0) h = 0; + else if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / d + 2) / 6; + else h = ((rn - gn) / d + 4) / 6; + + l = Math.min(1, l + amount / 100); + return hslToRgb(h, s, l); + } + + function hslToRgb(h, s, l) { + var r, g, b; + if (s === 0) { + r = g = b = l; + } else { + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255), + }; + } + + function hue2rgb(p, q, t) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + } + + // --- Palette generation (matches color.service.ts) --- + + function generatePalette(hex500) { + var baseColor = hexToRgb(hex500); + var white = { r: 255, g: 255, b: 255 }; + var baseDark = multiply(baseColor, baseColor); + + var shades = [ + { name: "50", rgb: mix(white, baseColor, 12) }, + { name: "100", rgb: mix(white, baseColor, 30) }, + { name: "200", rgb: mix(white, baseColor, 50) }, + { name: "300", rgb: mix(white, baseColor, 70) }, + { name: "400", rgb: mix(white, baseColor, 85) }, + { name: "500", rgb: mix(white, baseColor, 100) }, + { name: "600", rgb: mix(baseDark, baseColor, 87) }, + { name: "700", rgb: mix(baseDark, baseColor, 70) }, + { name: "800", rgb: mix(baseDark, baseColor, 54) }, + { name: "900", rgb: mix(baseDark, baseColor, 25) }, + { + name: "a100", + rgb: lighten(saturate(mix(baseDark, baseDark, 15), 80), 65), + }, + { + name: "a200", + rgb: lighten(saturate(mix(baseDark, baseDark, 15), 80), 55), + }, + { + name: "a400", + rgb: lighten(saturate(mix(baseDark, baseDark, 15), 100), 45), + }, + { + name: "a700", + rgb: lighten(saturate(mix(baseDark, baseDark, 15), 100), 40), + }, + ]; + + return shades.map(function (shade) { + var hex = rgbToHex(shade.rgb.r, shade.rgb.g, shade.rgb.b); + return { + name: shade.name, + hex: hex, + darkContrast: isLight(shade.rgb), + }; + }); + } + + // --- Apply theme to CSS custom properties --- + + function applyPalette(paletteName, shades) { + var root = document.documentElement; + shades.forEach(function (shade) { + root.style.setProperty( + "--theme-" + paletteName + "-" + shade.name, + shade.hex + ); + root.style.setProperty( + "--theme-" + paletteName + "-" + shade.name + "-contrast", + shade.darkContrast ? "rgba(0,0,0,0.87)" : "#ffffff" + ); + }); + } + + function applyThemeColors(themeData) { + var root = document.documentElement; + var palettes = ["primary", "accent", "warn"]; + + palettes.forEach(function (palette) { + var hex500 = themeData[palette + "_500"]; + if (hex500) { + // Generate full palette from the 500 shade + var shades = generatePalette(hex500); + applyPalette(palette, shades); + + // Override with any explicitly set shades from the theme + shades.forEach(function (shade) { + var key = palette + "_" + shade.name; + if (themeData[key]) { + root.style.setProperty( + "--theme-" + palette + "-" + shade.name, + themeData[key] + ); + var rgb = hexToRgb(themeData[key]); + root.style.setProperty( + "--theme-" + palette + "-" + shade.name + "-contrast", + isLight(rgb) ? "rgba(0,0,0,0.87)" : "#ffffff" + ); + } + }); + } + }); + + // Apply headbar color + if (themeData.headbar) { + root.style.setProperty("--theme-headbar", themeData.headbar); + } + } + + // --- Fetch theme from autoupdate endpoint --- + + function fetchTheme() { + var controller = + typeof AbortController !== "undefined" ? new AbortController() : null; + var timeoutId; + + if (controller) { + timeoutId = setTimeout(function () { + controller.abort(); + }, 3000); + } + + var fetchOptions = {}; + if (controller) { + fetchOptions.signal = controller.signal; + } + + fetch("/system/presenter/theme", fetchOptions) + .then(function (response) { + if (timeoutId) clearTimeout(timeoutId); + if (!response.ok) { + throw new Error("Theme endpoint returned " + response.status); + } + return response.json(); + }) + .then(function (themeData) { + applyThemeColors(themeData); + }) + .catch(function (err) { + if (timeoutId) clearTimeout(timeoutId); + console.warn("OpenSlides theme: using defaults.", err.message || err); + }); + } + + // --- Initialize --- + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", fetchTheme); + } else { + fetchTheme(); + } +})(); diff --git a/keycloak/themes/openslides/login/template.ftl b/keycloak/themes/openslides/login/template.ftl new file mode 100644 index 00000000..c953be5a --- /dev/null +++ b/keycloak/themes/openslides/login/template.ftl @@ -0,0 +1,63 @@ +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false> + + + + + + + + <#if properties.meta?has_content> + <#list properties.meta?split(' ') as meta> + + + + ${msg("loginTitle",(realm.displayName!''))} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + + + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + + + diff --git a/keycloak/themes/openslides/login/theme.properties b/keycloak/themes/openslides/login/theme.properties new file mode 100644 index 00000000..f7c12d6a --- /dev/null +++ b/keycloak/themes/openslides/login/theme.properties @@ -0,0 +1,5 @@ +parent=keycloak.v2 +import=common/keycloak +styles=css/openslides-login.css +scripts=js/openslides-theme.js +locales=en,de