diff --git a/CHANGELOG.md b/CHANGELOG.md index dc77902b6..f92a1e543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,12 +33,21 @@ and this project adheres to - Bump the Elasticsearch test service to `8.8.1` so its bundled JDK can read cgroup v2 hosts, fixing the `test-python` CI jobs - Fix XAPI definitions extensions not accepting empty strings as values. +- Fix type of OIDC ID tokens +- Fix error with OIDC scopes unrelated to Ralph +- Fix oidc test `test_api_auth_oidc_get_whoami_invalid_backend` + being misconfigured - Keep CORS disabled by default in `.env.dist` so a bootstrapped `.env` no longer breaks the `RUNSERVER_CORS_ALLOW_ORIGINS` settings unit tests ### Changed - Refactor statements' ExtensionMap +- Auth: changed default TTL of cache to 60 seconds +- OIDC: Add query to `/userinfo` endpoint when receiving a + token to support more OIDC IdPs +- OIDC: Add token introspection to support querying from OIDC clients + (Client Credentials flow) ## [5.0.1] - 2024-07-11 diff --git a/docs/tutorials/lrs/authentication/oidc.md b/docs/tutorials/lrs/authentication/oidc.md index d63aeb54d..5d890b715 100644 --- a/docs/tutorials/lrs/authentication/oidc.md +++ b/docs/tutorials/lrs/authentication/oidc.md @@ -2,14 +2,20 @@ Ralph LRS also supports OpenID Connect on top of OAuth 2.0 for authentication and authorization. -To enable OpenID Connect authentication mode, we should change the `RALPH_RUNSERVER_AUTH_BACKENDS` environment variable to `oidc` and we should define the `RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI` environment variable with the identity provider's Issuer Identifier URI as follows: +To enable OpenID Connect authentication mode, we should change the `RALPH_RUNSERVER_AUTH_BACKENDS` environment variable to `oidc` and we should define the environment variables as follows: + +- `RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI` the identity provider's Issuer Identifier URI + This address must be accessible to the LRS on startup as it will perform OpenID Connect Discovery to retrieve public keys and other information about the OpenID Connect environment. +- `RALPH_RUNSERVER_AUTH_OIDC_CLIENT_ID` the OIDC client id issued by the identity provider for this instance +- `RALPH_RUNSERVER_AUTH_OIDC_CLIENT_SECRET` the OIDC client secret issued by the identity provider for this instance ```bash RALPH_RUNSERVER_AUTH_BACKENDS=oidc RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI=http://{provider_host}:{provider_port}/auth/realms/{realm_name} +RALPH_RUNSERVER_AUTH_OIDC_CLIENT_ID=some_client_id +RALPH_RUNSERVER_AUTH_OIDC_CLIENT_SECRET=some_client_secret ``` -This address must be accessible to the LRS on startup as it will perform OpenID Connect Discovery to retrieve public keys and other information about the OpenID Connect environment. It is also strongly recommended to set the optional `RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE` environment variable to the origin address of Ralph LRS itself (e.g. "http://localhost:8100") to enable verification that a given token was issued specifically for that Ralph LRS. @@ -74,7 +80,7 @@ services: networks: ralph: external: true - + ``` Again, we need to create the `.ralph` directory: @@ -102,7 +108,7 @@ Now that both Keycloak and Ralph LRS server are up and running, we should be abl ``` ```bash - {"access_token":"","expires_in":300,"refresh_expires_in":1800,"refresh_token":"","token_type":"Bearer","not-before-policy":0,"session_state":"0889b3a5-d742-45fb-98b3-20e967960e74","scope":"email profile"} + {"access_token":"","expires_in":300,"refresh_expires_in":1800,"refresh_token":"","token_type":"Bearer","not-before-policy":0,"session_state":"0889b3a5-d742-45fb-98b3-20e967960e74","scope":"email profile"} ``` === "HTTPie" @@ -134,12 +140,12 @@ Now that both Keycloak and Ralph LRS server are up and running, we should be abl With this access token, we can now make a request to the Ralph LRS server: === "curl" - + ```bash curl -H 'Authorization: Bearer ' \ http://localhost:8100/whoami ``` - + ```bash {"agent":{"openid":"http://localhost:8080/auth/realms/fun-mooc/b6e85bd0-ce6e-4b24-9f0e-6e18d8744e54"},"scopes":["email","profile"]} ``` diff --git a/src/helm/ralph/templates/cm_lrs.yaml b/src/helm/ralph/templates/cm_lrs.yaml index 89f69b817..0f405021d 100644 --- a/src/helm/ralph/templates/cm_lrs.yaml +++ b/src/helm/ralph/templates/cm_lrs.yaml @@ -9,7 +9,7 @@ data: RALPH_APP_DIR: {{ .Values.lrs.appDir | quote }} RALPH_RUNSERVER_BACKEND: {{ .Values.lrs.backend | quote }} RALPH_RUNSERVER_MAX_SEARCH_HITS_COUNT: {{ .Values.lrs.maxSearchHitsCount | quote }} - + # CLI RALPH_CONVERTER_EDX_XAPI_UUID_NAMESPACE: {{ .Values.lrs.converterNamespace | quote }} @@ -31,6 +31,7 @@ data: {{- if .Values.lrs.auth.oidc.enabled }} RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE: {{ .Values.lrs.authOIDCAudience | quote }} RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI: {{ .Values.lrs.authOIDCIssuerURI | quote }} + RALPH_AUTH_OIDC_CACHE_TTL: {{ .Values.lrs.auth.oidc.cacheTTL | quote }} {{ end }} # Sentry @@ -41,4 +42,3 @@ data: RALPH_SENTRY_LRS_TRACES_SAMPLE_RATE: {{ .Values.lrs.sentry.lrsSampleRate | quote }} RALPH_SENTRY_IGNORE_HEALTH_CHECKS: {{ .Values.lrs.sentry.ignoreHealthChecks | quote }} {{- end }} - diff --git a/src/helm/ralph/templates/cronjob.yaml b/src/helm/ralph/templates/cronjob.yaml index acaed2e14..60a5ba01d 100644 --- a/src/helm/ralph/templates/cronjob.yaml +++ b/src/helm/ralph/templates/cronjob.yaml @@ -36,7 +36,7 @@ spec: {{- toYaml .Values.podSecurityContext | nindent 12 }} containers: - name: "{{ template "ralph.fullname" . }}-{{ $job.name }}" - securityContext: + securityContext: {{- toYaml .Values.securityContext | nindent 16 }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} diff --git a/src/helm/ralph/values.yaml b/src/helm/ralph/values.yaml index 9d31769cd..24add85b4 100644 --- a/src/helm/ralph/values.yaml +++ b/src/helm/ralph/values.yaml @@ -139,6 +139,7 @@ lrs: enabled: false audience: "http://localhost:8100" issuerURI: "http://learning-analytics-playground_keycloak_1:8080/auth/realms/fun-mooc" + cacheTTL: 60 sentry: enabled: false dsn: "https://fake@key.ingest.sentry.io/1234567" diff --git a/src/ralph/api/auth/oidc.py b/src/ralph/api/auth/oidc.py index 903d9b543..65982c828 100644 --- a/src/ralph/api/auth/oidc.py +++ b/src/ralph/api/auth/oidc.py @@ -1,10 +1,13 @@ """OpenID Connect authentication tool for the Ralph API.""" +import base64 import logging from functools import lru_cache -from typing import Dict, Optional +from threading import Lock +from typing import Dict, Literal, Optional, Union, get_args import requests +from cachetools import TTLCache, cached from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, OpenIdConnect from jose import ExpiredSignatureError, JWTError, jwt @@ -12,7 +15,7 @@ from pydantic import AnyUrl, BaseModel, ConfigDict from typing_extensions import Annotated -from ralph.api.auth.user import AuthenticatedUser, UserScopes +from ralph.api.auth.user import AuthenticatedUser, Scope, UserScopes from ralph.conf import settings OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration" @@ -26,35 +29,89 @@ logger = logging.getLogger(__name__) -class IDToken(BaseModel): - """Pydantic model representing the core of an OpenID Connect ID Token. +class UserInfo(BaseModel): + """Pydantic model representing the UserInfo response of the OIDC IdP. - ID Tokens are polymorphic and may have many attributes not defined in the + They are common to both the ID token and the UserInfo endpoint. + We do not use it for authentication, so may claims are ignored. + They are polymorphic and may have many attributes not defined in the specification. This model ignores all additional fields. Attributes: - iss (str): Issuer Identifier for the Issuer of the response. sub (str): Subject Identifier. - aud (str): Audience(s) that this ID Token is intended for. + scope (str): Scope(s) for resource authorization. + target (str): Target for storing the statements (custom claim). + """ + + sub: str + scope: Optional[str] = None + target: Optional[str] = None + + model_config = ConfigDict(extra="ignore") + + +class TokenIntrospection(BaseModel): + """Pydantic model representing the Introspection response of the OIDC IdP. + + Based on the RFC 7662 section 2.2 definition of token /introspect response + This model does not use all fields defined in the RFC, + and we force some optional fields to be present. + + The 'active' field is assumed to be true and is not included in this model. + + Attributes: + client_id (str): ID of the client that owns this token + username (str): Name of the user referred to by this this token, if any + iss (str): Issuer Identifier for the Issuer of the response. + sub (str): Subject Identifier, if any + No sub means that this is a purely 'client' token + aud (str or list of str): Audience(s) that this ID Token is intended for. exp (int): Expiration time on or after which the ID Token MUST NOT be accepted for processing. iat (int): Time at which the JWT was issued. scope (str): Scope(s) for resource authorization. - target (str): Target for storing the statements. + target (str): Target for storing the statements (custom claim). + """ + client_id: str + username: Optional[str] = None + token_type: Optional[str] = None iss: str - sub: str - aud: Optional[str] = None - exp: int - iat: int + sub: Optional[str] = None + aud: Optional[Union[list[str], str]] = None + exp: float + iat: float scope: Optional[str] = None + target: Optional[str] = None model_config = ConfigDict(extra="ignore") -@lru_cache() +def make_authenticated_oidc_user(iss: str, user_info: UserInfo) -> AuthenticatedUser: + """Factory function for `AuthenticatedUser` when it is an OIDC user.""" + return AuthenticatedUser( + agent={"openid": f"{iss}/{user_info.sub}"}, + scopes=get_user_scopes(user_info.scope), + target=user_info.target, + ) + + +def make_authenticated_oidc_client(token_info: TokenIntrospection) -> AuthenticatedUser: + """Factory function for `AuthenticatedUser` when it is a OIDC client. + + This is an application token, we don't have a user to get. + So we use the client_id to indentify it instead + """ + return AuthenticatedUser( + agent={"openid": f"{token_info.iss}/application/{token_info.client_id}"}, + scopes=get_user_scopes(token_info.scope), + target=token_info.target, + ) + + +@lru_cache(maxsize=1) def discover_provider(base_url: AnyUrl) -> Dict: """Discover the authentication server (or OpenId Provider) configuration.""" try: @@ -72,7 +129,155 @@ def discover_provider(base_url: AnyUrl) -> Dict: ) from exc -@lru_cache() +def get_user_info(provider_config: dict, auth_header: str) -> UserInfo: + """Get the user's info from the IdP using the /userinfo OIDC endpoint.""" + user_info, is_encoded = get_user_info_data( + userinfo_endpoint=provider_config["userinfo_endpoint"], auth_header=auth_header + ) + if not is_encoded: + # nothing to do + return UserInfo.model_validate(user_info) + token = decode_jwt_token(user_info, provider_config=provider_config) + return UserInfo.model_validate(token) + + +@cached( + cache=TTLCache( + maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_OIDC_CACHE_TTL + ), + lock=Lock(), +) +def get_user_info_data( + userinfo_endpoint: AnyUrl, auth_header: str +) -> Union[tuple[dict, Literal[False]], tuple[str, Literal[True]]]: + """Get the user's info from the IdP using the /userinfo OIDC endpoint. + + The data may be unencoded (Content-Type: 'application/json', + in which case it is a json dictionary. + If it is encoded (Content-Type: 'application/jwt', it is a JWT + see: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + + Returns the data and whether that data is encoded (a JWT string) or plain (a dict) + """ + try: + response = requests.get( + f"{userinfo_endpoint}", + headers={"Authorization": auth_header}, + timeout=5, + ) + response.raise_for_status() + content_type = response.headers["Content-Type"] + media_type = content_type.split(";", 1)[0].strip().lower() + is_jwt = media_type == "application/jwt" + is_json = media_type == "application/json" + if not is_jwt and not is_json: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Invalid Media type in header: {media_type}, " + "expected application/jwt or application/json" + ), + headers={"WWW-Authenticate": "Bearer"}, + ) + body = response.text if is_jwt else response.json() + return (body, is_jwt) + except requests.exceptions.RequestException as exc: + logger.error("Unable to get the user's ID token: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + +def decode_jwt_token(encoded_user_info: str, provider_config: dict) -> dict: + """Decode and verify an OpenID Connect ID token.""" + key = get_public_keys(provider_config["jwks_uri"]) + algorithms = provider_config["id_token_signing_alg_values_supported"] + audience = settings.RUNSERVER_AUTH_OIDC_AUDIENCE + options = { + "verify_signature": True, + "verify_aud": bool(audience), + "verify_exp": True, + } + try: + return jwt.decode( + token=encoded_user_info, + key=key, + algorithms=algorithms, + options=options, + audience=audience, + ) + except (ExpiredSignatureError, JWTError, JWTClaimsError) as exc: + logger.error("Unable to decode the ID token: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + +def encode_client_secret_basic_token(client_id: str, client_secret: str) -> str: + """Encode client id and client secret inside an opaque token. + + Should be sent as Authorization: Basic {token} + + This corresponds to the `client_secret_basic` + token endpoint authentication method. + """ + return base64.b64encode( + client_id.encode("utf-8") + b":" + client_secret.encode("utf-8") + ).decode("utf-8") + + +def get_client_basic_auth_header(client_id: str, client_secret: str) -> str: + """Get a `client_secret_basic` token endpoint authentication header.""" + token = encode_client_secret_basic_token( + client_id=client_id, client_secret=client_secret + ) + return f"Basic {token}" + + +@cached( + cache=TTLCache( + maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_OIDC_CACHE_TTL + ), + lock=Lock(), +) +def get_token_introspection( + introspection_endpoint: AnyUrl, token: str, client_basic_auth_header: str +) -> TokenIntrospection: + """Get info on given token from the IdP using /introspection OIDC endpoint.""" + token_info = None + try: + response = requests.post( + f"{introspection_endpoint}", + headers={"Authorization": client_basic_auth_header}, + data={ + "token": f"{token}", + }, + timeout=5, + ) + response.raise_for_status() + token_info = response.json() + except requests.exceptions.RequestException as exc: + logger.error("Unable to get token info: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + if not token_info["active"]: + logger.error("Inactive or invalid token info.") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + return TokenIntrospection.model_validate(token_info) + + +@lru_cache(maxsize=1) def get_public_keys(jwks_uri: AnyUrl) -> Dict: """Retrieve the public keys used by the provider server for signing.""" try: @@ -94,6 +299,33 @@ def get_public_keys(jwks_uri: AnyUrl) -> Dict: ) from exc +def get_user_scopes(oidc_scopes: Optional[str]) -> UserScopes: + """Extract Ralph's custom OAuth2 scopes from the global scope list. + + Ignore incompatible scopes. + """ + compatible_scopes = ( + [scope for scope in oidc_scopes.split(" ") if scope in get_args(Scope)] + if oidc_scopes + else [] + ) + return UserScopes(compatible_scopes) + + +def _can_query_user_info(provider_config: dict) -> bool: + """Check whether the IdP can be queried for `UserInfo` using an access token. + + True when Ralph has client credentials and the IdP exposes + both `/introspect` and `/userinfo` endpoints. + """ + return bool( + settings.RUNSERVER_AUTH_OIDC_CLIENT_ID + and settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET + and provider_config.get("introspection_endpoint") + and provider_config.get("userinfo_endpoint") + ) + + def get_oidc_user( auth_header: Annotated[Optional[HTTPBearer], Depends(oauth2_scheme)], ) -> AuthenticatedUser: @@ -117,37 +349,42 @@ def get_oidc_user( ) return None - id_token = auth_header.split(" ")[-1] + access_token = auth_header.split(" ")[-1] provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI) - key = get_public_keys(provider_config["jwks_uri"]) - algorithms = provider_config["id_token_signing_alg_values_supported"] - audience = settings.RUNSERVER_AUTH_OIDC_AUDIENCE - options = { - "verify_signature": True, - "verify_aud": bool(audience), - "verify_exp": True, - } - try: - decoded_token = jwt.decode( - token=id_token, - key=key, - algorithms=algorithms, - options=options, - audience=audience, + + if not _can_query_user_info(provider_config): + # We have to assume that the access token is an ID Token + # in the JWT format, and can be decoded offline. + id_token = decode_jwt_token( + encoded_user_info=access_token, provider_config=provider_config ) - except (ExpiredSignatureError, JWTError, JWTClaimsError) as exc: - logger.error("Unable to decode the ID token: %s", exc) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from exc + user_info = UserInfo.model_validate(id_token) + return make_authenticated_oidc_user(iss=id_token["iss"], user_info=user_info) - id_token = IDToken.model_validate(decoded_token) + client_basic_auth_header = get_client_basic_auth_header( + client_id=settings.RUNSERVER_AUTH_OIDC_CLIENT_ID, + client_secret=settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET, + ) - user = AuthenticatedUser( - agent={"openid": f"{id_token.iss}/{id_token.sub}"}, - scopes=UserScopes(id_token.scope.split(" ") if id_token.scope else []), - target=id_token.target, + token_info = get_token_introspection( + provider_config["introspection_endpoint"], + token=access_token, + client_basic_auth_header=client_basic_auth_header, ) - return user + + if not token_info.sub: + return make_authenticated_oidc_client(token_info) + else: + # This is a real user, we can retrieve their user info + user_info = get_user_info(provider_config, auth_header=auth_header) + if user_info.sub != token_info.sub: + logger.error( + ("Inconsistent token subject: %s != %s"), user_info.sub, token_info.sub + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return make_authenticated_oidc_user(iss=token_info.iss, user_info=user_info) diff --git a/src/ralph/conf.py b/src/ralph/conf.py index e6edec783..04d0ab92a 100644 --- a/src/ralph/conf.py +++ b/src/ralph/conf.py @@ -1,6 +1,7 @@ """Configurations for Ralph.""" import io +import os from enum import Enum from pathlib import Path from typing import Any, List, Optional, Tuple, Union @@ -43,7 +44,11 @@ NonEmptyStrictStr = Annotated[str, StringConstraints(min_length=1, strict=True)] BASE_SETTINGS_CONFIG = SettingsConfigDict( - case_sensitive=True, env_nested_delimiter="__", env_prefix="RALPH_", extra="ignore" + case_sensitive=True, + env_nested_delimiter="__", + env_prefix="RALPH_", + extra="ignore", + secrets_dir=os.environ.get("RALPH_SECRETS_DIR"), ) @@ -215,6 +220,7 @@ class Settings(BaseSettings): AUTH_FILE: Path = _CORE.APP_DIR / "auth.json" AUTH_CACHE_MAX_SIZE: int = 100 AUTH_CACHE_TTL: int = 3600 + AUTH_OIDC_CACHE_TTL: int = 60 CONVERTER_EDX_XAPI_UUID_NAMESPACE: Optional[str] = None EXECUTION_ENVIRONMENT: str = "development" HISTORY_FILE: Path = _CORE.APP_DIR / "history.json" @@ -255,6 +261,8 @@ class Settings(BaseSettings): ) RUNSERVER_AUTH_OIDC_AUDIENCE: Optional[str] = None RUNSERVER_AUTH_OIDC_ISSUER_URI: Optional[AnyHttpUrl] = None + RUNSERVER_AUTH_OIDC_CLIENT_ID: Optional[str] = None + RUNSERVER_AUTH_OIDC_CLIENT_SECRET: Optional[str] = None RUNSERVER_BACKEND: str = "es" RUNSERVER_HOST: str = "0.0.0.0" # noqa: S104 RUNSERVER_MAX_SEARCH_HITS_COUNT: int = 100 diff --git a/tests/api/auth/test_oidc.py b/tests/api/auth/test_oidc.py index c128662cf..89329ff92 100644 --- a/tests/api/auth/test_oidc.py +++ b/tests/api/auth/test_oidc.py @@ -1,14 +1,31 @@ """Tests for the api.auth.oidc module.""" +import json + import pytest import responses +from fastapi import HTTPException from pydantic import TypeAdapter -from ralph.api.auth.oidc import discover_provider, get_public_keys +from ralph.api.auth.oidc import ( + TokenIntrospection, + UserInfo, + discover_provider, + get_public_keys, + get_token_introspection, + get_user_info, + get_user_info_data, +) from ralph.conf import AuthBackend from ralph.models.xapi.base.agents import BaseXapiAgentWithOpenId -from tests.fixtures.auth import ISSUER_URI, mock_oidc_user +from tests.fixtures.auth import ( + ISSUER_URI, + OTHER_CLIENT_ID, + TOKEN_ISS, + encode_jwt, + mock_oidc_user, +) from tests.fixtures.backends import get_es_test_backend from tests.helpers import ( assert_statement_get_responses_are_equivalent, @@ -19,18 +36,168 @@ @pytest.mark.anyio @pytest.mark.parametrize( - "runserver_auth_backends", - [[AuthBackend.BASIC, AuthBackend.OIDC], [AuthBackend.OIDC]], + "response_content_type,is_valid,token_data", + [ + ("application/json", True, {"sub": "my_user_2", "scope": "statements/write"}), + ("application/jwt", True, {"sub": "my_user_1", "scope": "statements/write"}), + ], ) @responses.activate -async def test_api_auth_oidc_get_whoami_valid( - client, monkeypatch, runserver_auth_backends +async def test_api_auth_oidc_userinfo( + mock_discovery_response, + mock_oidc_jwks, + response_content_type, + is_valid, + token_data, +): + + user_info = UserInfo(**token_data) + auth_header = "Bearer a_token" + + # Cache clear + get_user_info_data.cache_clear() + + response_body = token_data + if response_content_type == "application/json": + response_body = json.dumps(token_data) + elif response_content_type == "application/jwt": + responses.add( + responses.GET, + mock_discovery_response["jwks_uri"], + json=mock_oidc_jwks, + status=200, + headers={"Content-Type": "application/json"}, + ) + algorithms = mock_discovery_response["id_token_signing_alg_values_supported"] + response_body = encode_jwt(algorithm=algorithms[0], claims=token_data) + responses.add( + responses.GET, + mock_discovery_response["userinfo_endpoint"], + body=response_body, + status=200, + headers={"Content-Type": response_content_type}, + ) + + if is_valid: + res_user_info = get_user_info(mock_discovery_response, auth_header=auth_header) + + assert res_user_info == user_info + else: + with pytest.raises(HTTPException) as exc_info: + get_user_info(mock_discovery_response, auth_header=auth_header) + assert exc_info.value.status_code == 400 + assert "text/html" in exc_info.value.detail + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "active,access_token,token_data", + [ + ( + True, + "a_token", + { + "iss": TOKEN_ISS, + "client_id": "client_1", + "sub": "my_user_1", + "scope": "statements/write", + "exp": 3600, + "iat": 0, + }, + ), + ( + True, + "another_token", + { + "iss": TOKEN_ISS, + "client_id": "client_2", + "scope": "statements/write", + "exp": 3600, + "iat": 0, + }, + ), + ( + False, + "an_invalid_token", + { + "iss": TOKEN_ISS, + "client_id": "client_1", + "sub": "my_user_2", + "scope": "statements/write", + "exp": 3600, + "iat": 0, + }, + ), + ], +) +@responses.activate +async def test_api_auth_oidc_introspection( + mock_discovery_response, active, access_token, token_data +): + + client_basic_auth_header = "Basic aaaaa" + + # Cache clear + get_user_info_data.cache_clear() + + response_body = {**token_data, "active": active} + + responses.add( + responses.POST, + mock_discovery_response["introspection_endpoint"], + json=response_body, + status=200, + headers={"Content-Type": "application/json"}, + ) + if active: + token_info = TokenIntrospection(**token_data) + res_token_info = get_token_introspection( + mock_discovery_response["introspection_endpoint"], + token=access_token, + client_basic_auth_header=client_basic_auth_header, + ) + assert res_token_info == token_info + else: + with pytest.raises(HTTPException) as exc_info: + get_token_introspection( + mock_discovery_response["introspection_endpoint"], + token=access_token, + client_basic_auth_header=client_basic_auth_header, + ) + assert exc_info.value.status_code == 401 + + +@pytest.mark.anyio +@responses.activate +@pytest.mark.parametrize( + "runserver_auth_backends,sub,enable_oidc_client,userinfo_response_type", + [ + ([AuthBackend.BASIC, AuthBackend.OIDC], "user_1", True, "jwt"), + ([AuthBackend.OIDC], "user_2", True, "plain"), + ([AuthBackend.OIDC], "user_3", True, "jwt"), + ([AuthBackend.OIDC], None, True, "jwt"), + ([AuthBackend.OIDC], "user_4", False, None), + ], +) +async def test_api_auth_oidc_get_whoami_valid( # noqa: PLR0913 + client, + monkeypatch, + runserver_auth_backends, + sub, + enable_oidc_client, + userinfo_response_type, ): """Test a valid OpenId Connect authentication.""" - configure_env_for_mock_oidc_auth(monkeypatch, runserver_auth_backends) + configure_env_for_mock_oidc_auth( + monkeypatch, runserver_auth_backends, enable_oidc_client=enable_oidc_client + ) - oidc_token = mock_oidc_user(scopes=["all", "profile/read"]) + oidc_token = mock_oidc_user( + sub=sub, + scopes=["all", "profile/read"], + userinfo_response_type=userinfo_response_type, + ) headers = {"Authorization": f"Bearer {oidc_token}"} response = await client.get( @@ -39,10 +206,16 @@ async def test_api_auth_oidc_get_whoami_valid( ) assert response.status_code == 200 assert len(response.json().keys()) == 2 - assert response.json()["agent"] == { - "openid": "https://iss.example.com/123|oidc", + agent = { + "openid": ( + f"{TOKEN_ISS}/application/{OTHER_CLIENT_ID}" + if sub is None + else f"{TOKEN_ISS}/{sub}" + ), "objectType": "Agent", } + + assert response.json()["agent"] == agent assert TypeAdapter(BaseXapiAgentWithOpenId).validate_python( response.json()["agent"] ) @@ -52,12 +225,16 @@ async def test_api_auth_oidc_get_whoami_valid( @pytest.mark.anyio @pytest.mark.parametrize( - "runserver_auth_backends", - [[AuthBackend.BASIC, AuthBackend.OIDC], [AuthBackend.OIDC]], + "runserver_auth_backends,userinfo_response_type", + [ + ([AuthBackend.BASIC, AuthBackend.OIDC], "jwt"), + ([AuthBackend.OIDC], "plain"), + ([AuthBackend.OIDC], "jwt"), + ], ) @responses.activate async def test_api_auth_oidc_post_statements_to_target( - client, monkeypatch, runserver_auth_backends, es_custom + client, monkeypatch, runserver_auth_backends, es_custom, userinfo_response_type ): """Test a valid OpenId Connect authentication.""" @@ -65,7 +242,11 @@ async def test_api_auth_oidc_post_statements_to_target( # Create user pointing to a custom target target = "custom_target" - oidc_token = mock_oidc_user(scopes=["all", "profile/read"], target=target) + oidc_token = mock_oidc_user( + scopes=["all", "profile/read"], + target=target, + userinfo_response_type=userinfo_response_type, + ) monkeypatch.setattr( "ralph.api.routers.statements.BACKEND_CLIENT", get_es_test_backend() @@ -105,15 +286,19 @@ async def test_api_auth_oidc_post_statements_to_target( @pytest.mark.anyio +@pytest.mark.parametrize( + "userinfo_response_type", + ["jwt", "plain"], +) @responses.activate async def test_api_auth_oidc_get_whoami_invalid_token( - client, monkeypatch, mock_discovery_response, mock_oidc_jwks + client, monkeypatch, userinfo_response_type ): """Test API with an invalid audience.""" configure_env_for_mock_oidc_auth(monkeypatch) - mock_oidc_user() + mock_oidc_user(userinfo_response_type=userinfo_response_type) response = await client.get( "/whoami", @@ -128,7 +313,7 @@ async def test_api_auth_oidc_get_whoami_invalid_token( @pytest.mark.anyio @responses.activate async def test_api_auth_oidc_get_whoami_invalid_discovery( - client, monkeypatch, encoded_token + client, monkeypatch, access_token ): """Test API with an invalid provider discovery.""" @@ -137,6 +322,8 @@ async def test_api_auth_oidc_get_whoami_invalid_discovery( # Clear LRU cache discover_provider.cache_clear() get_public_keys.cache_clear() + get_token_introspection.cache_clear() + get_user_info_data.cache_clear() # Mock request to get provider configuration responses.add( @@ -148,7 +335,7 @@ async def test_api_auth_oidc_get_whoami_invalid_discovery( response = await client.get( "/whoami", - headers={"Authorization": f"Bearer {encoded_token}"}, + headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == 401 @@ -159,7 +346,7 @@ async def test_api_auth_oidc_get_whoami_invalid_discovery( @pytest.mark.anyio @responses.activate async def test_api_auth_oidc_get_whoami_invalid_keys( - client, monkeypatch, mock_discovery_response, mock_oidc_jwks, encoded_token + client, monkeypatch, mock_discovery_response, mock_oidc_jwks, access_token ): """Test API with an invalid request for keys.""" @@ -187,7 +374,7 @@ async def test_api_auth_oidc_get_whoami_invalid_keys( response = await client.get( "/whoami", - headers={"Authorization": f"Bearer {encoded_token}"}, + headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == 401 @@ -215,6 +402,7 @@ async def test_api_auth_oidc_get_whoami_invalid_header(client, monkeypatch): @pytest.mark.anyio +@responses.activate async def test_api_auth_oidc_get_whoami_invalid_backend(client, fs, monkeypatch): """Check for an exception when providing valid OIDC credentials while OIDC authentication is not supported. @@ -231,4 +419,4 @@ async def test_api_auth_oidc_get_whoami_invalid_backend(client, fs, monkeypatch) ) assert response.status_code == 401 - assert response.json() == {"detail": "Could not validate credentials"} + assert response.json() == {"detail": "Invalid authentication credentials"} diff --git a/tests/api/test_statements_get.py b/tests/api/test_statements_get.py index b6bc6d691..070e196c3 100644 --- a/tests/api/test_statements_get.py +++ b/tests/api/test_statements_get.py @@ -30,7 +30,15 @@ get_mongo_test_backend, ) -from ..fixtures.auth import AUDIENCE, ISSUER_URI, mock_basic_auth_user, mock_oidc_user +from ..fixtures.auth import ( + AUDIENCE, + CLIENT_ID, + CLIENT_SECRET, + ISSUER_URI, + TOKEN_ISS, + mock_basic_auth_user, + mock_oidc_user, +) from ..helpers import mock_activity, mock_agent @@ -889,20 +897,25 @@ async def test_api_statements_get_scopes( # noqa: PLR0913 "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", AUDIENCE, ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_ID", + CLIENT_ID, + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET", + CLIENT_SECRET, + ) sub = "123|oidc" - iss = "https://iss.example.com" - agent = {"openid": f"{iss}/{sub}"} + agent = {"openid": f"{TOKEN_ISS}/{sub}"} oidc_token = mock_oidc_user(sub=sub, scopes=scopes) headers = {"Authorization": f"Bearer {oidc_token}"} monkeypatch.setattr( - "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_ISSUER_URI", - "http://providerHost:8080/auth/realms/real_name", + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_ISSUER_URI", ISSUER_URI ) monkeypatch.setattr( - "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", - "http://clientHost:8100", + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", AUDIENCE ) # Mock statements diff --git a/tests/api/test_statements_post.py b/tests/api/test_statements_post.py index 3cd5f0e37..ee7c7cba9 100644 --- a/tests/api/test_statements_post.py +++ b/tests/api/test_statements_post.py @@ -16,6 +16,8 @@ from tests.fixtures.auth import ( AUDIENCE, + CLIENT_ID, + CLIENT_SECRET, ISSUER_URI, mock_basic_auth_user, mock_oidc_user, @@ -804,6 +806,14 @@ async def test_api_statements_post_scopes( # noqa: PLR0913 "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", AUDIENCE, ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_ID", + CLIENT_ID, + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET", + CLIENT_SECRET, + ) statement = mock_statement() diff --git a/tests/api/test_statements_put.py b/tests/api/test_statements_put.py index 88265e440..e130cbf06 100644 --- a/tests/api/test_statements_put.py +++ b/tests/api/test_statements_put.py @@ -15,6 +15,8 @@ from tests.fixtures.auth import ( AUDIENCE, + CLIENT_ID, + CLIENT_SECRET, ISSUER_URI, mock_basic_auth_user, mock_oidc_user, @@ -691,6 +693,14 @@ async def test_api_statements_put_scopes( # noqa: PLR0913 "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", AUDIENCE, ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_ID", + CLIENT_ID, + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET", + CLIENT_SECRET, + ) statement = mock_statement() diff --git a/tests/conftest.py b/tests/conftest.py index 1a2f61efb..aa379f2cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,8 +2,8 @@ from .fixtures.api import client # noqa: F401 from .fixtures.auth import ( # noqa: F401 + access_token, basic_auth_credentials, - encoded_token, mock_discovery_response, mock_oidc_jwks, ) diff --git a/tests/fixtures/auth.py b/tests/fixtures/auth.py index 68326a5f8..5f59a6600 100644 --- a/tests/fixtures/auth.py +++ b/tests/fixtures/auth.py @@ -3,7 +3,8 @@ import base64 import json import os -from typing import Optional +import urllib.parse +from typing import Callable, Literal, Optional, Union import bcrypt import pytest @@ -21,6 +22,10 @@ ALGORITHM = "RS256" AUDIENCE = "http://clientHost:8100" ISSUER_URI = "http://providerHost:8080/auth/realms/real_name" +TOKEN_ISS = "https://iss.example.com" +CLIENT_ID = "my-client-id" +OTHER_CLIENT_ID = "my-other-client-id" +CLIENT_SECRET = "my-client-secret" PUBLIC_KEY_ID = "example-key-id" @@ -116,6 +121,7 @@ def _mock_discovery_response(): "authorization_endpoint": "https://providerHost:8080/auth/oauth/v2/authorize", "token_endpoint": "https://providerHost:8080/auth/oauth/v2/token", "jwks_uri": "https://providerHost:8080/openid/connect/jwks.json", + "introspection_endpoint": "https://providerHost:8080/auth/oauth/v2/introspect", "response_types_supported": [ "code", "token id_token", @@ -218,6 +224,22 @@ def get_jwk(pub_key): } +def encode_jwt( + claims, algorithm: str = "HS256", headers: dict = None, access_token: str = None +): + return jwt.encode( + access_token=access_token, + algorithm=algorithm, + key=private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + headers=headers, + claims=claims, + ) + + def _mock_oidc_jwks(): """Mock OpenID Connect keys.""" return {"keys": [get_jwk(public_key)]} @@ -229,63 +251,220 @@ def mock_oidc_jwks(): return _mock_oidc_jwks() -def _create_oidc_token(sub, scopes, target=None): - """Encode token with the private key.""" - claims = { - "sub": sub, - "iss": "https://iss.example.com", +def _mock_access_token(sub, scopes, target=None): + return base64.urlsafe_b64encode( + f"opaque_string_{sub}_{scopes}_{target}".encode() + ).decode() + + +def _mock_oidc_introspection_response(sub, scopes, target=None): + """Mock OIDC Token Introspection response with provided params.""" + token_introspection = { + "iss": TOKEN_ISS, "aud": AUDIENCE, "iat": 0, # Issued the 1/1/1970 "exp": 9999999999, # Expiring in 11/20/2286 "scope": " ".join(scopes), + "active": True, + "client_id": OTHER_CLIENT_ID, + "token_type": "Bearer", } + if sub is not None: + token_introspection["sub"] = sub if target is not None: - claims["target"] = target - return jwt.encode( - claims=claims, - key=private_key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ), - algorithm=ALGORITHM, - headers={ - "kid": PUBLIC_KEY_ID, - }, - ) + token_introspection["target"] = target + return token_introspection + + +def _mock_oidc_user_info_plain_response(sub: str, scopes, target=None): + """Mock unencoded OIDC user info claims with provided params.""" + if sub is None: + raise ValueError( + "The IdP `/userinfo` endpoint cannot " + "return `UserInfo` without a `sub` claim." + ) + user_info = { + "sub": sub, + "scope": " ".join(scopes), + } + if target is not None: + user_info["target"] = target + return user_info -def mock_oidc_user(sub="123|oidc", scopes=None, target=None): - """Instantiate mock oidc user and return auth token.""" +def protect_oidc_client_basic_callback( + result: Union[dict, Callable[[dict], tuple]], client_id: str, client_secret: str +): + def _callback(request): + auth_header = request.headers["Authorization"] + auth_method = auth_header.split(" ")[0] + if auth_method.lower() != "basic": + return (401, {}, "") + client_secret_basic_token = auth_header.split(" ")[-1] + decoded_client_secret_basic_token = base64.b64decode( + client_secret_basic_token.encode("utf-8") + ).decode("utf-8") + id = decoded_client_secret_basic_token.split(":")[0] + secret = decoded_client_secret_basic_token.split(":")[1] + if id != client_id or secret != client_secret: + return (401, {}, "") + if isinstance(result, Callable): + return result(request) + return ( + 200, + {"Content-Type": "application/json"}, + json.dumps(result), + ) + + return _callback + + +def protect_oidc_token_callback( + result: Union[dict, Callable[[dict], tuple]], access_token: str +): + def _callback(request): + auth_header = request.headers["Authorization"] + auth_method = auth_header.split(" ")[0] + if auth_method.lower() != "bearer": + return (401, {}, "") + token = auth_header.split(" ")[-1] + if token != access_token: + return (401, {}, "") + if isinstance(result, Callable): + return result(request) + return ( + 200, + {"Content-Type": "application/json"}, + json.dumps(result), + ) + + return _callback + + +def mock_oidc_user( + sub: Union[str, None] = "123|oidc", + scopes=None, + target=None, + userinfo_response_type: Union[Literal["plain", "jwt"], None] = "jwt", +): + """Instantiate mock oidc user and return auth token. + + If `userinfo_response_type` is None, produces an OIDC Id token in the JWT format, + from which the `UserInfo` can be decoded without another request to IdP. + Otherwise, creates an opaque access token. + If `sub` is None, the access token can be inspected with + the IdP's `/introspection`. + If `sub` is not None, the access token can be traded for`UserInfo` using + the IdP's `/introspection` and `/userinfo` endpoints. + """ # Default value for scope if scopes is None: scopes = ["all", "statements/read"] + if sub is None and userinfo_response_type is None: + raise ValueError( + "Cannot return a Client Credentials access token" + "if not using the IdP's `/introspection` and `/userinfo` endpoints" + ) + # Clear LRU cache discover_provider.cache_clear() get_public_keys.cache_clear() + provider_config = _mock_discovery_response() # Mock request to get provider configuration responses.add( responses.GET, f"{ISSUER_URI}/.well-known/openid-configuration", - json=_mock_discovery_response(), + json=provider_config, status=200, ) # Mock request to get keys responses.add( responses.GET, - _mock_discovery_response()["jwks_uri"], + provider_config["jwks_uri"], json=_mock_oidc_jwks(), status=200, ) - oidc_token = _create_oidc_token(sub=sub, scopes=scopes, target=target) - return oidc_token + if userinfo_response_type is None: + user_info = _mock_oidc_user_info_plain_response( + sub=sub, scopes=scopes, target=target + ) + oidc_jwt_token = encode_jwt( + claims={**user_info, "iss": TOKEN_ISS}, + algorithm=ALGORITHM, + headers={ + "kid": PUBLIC_KEY_ID, + }, + ) + return oidc_jwt_token + + oidc_access_token = _mock_access_token(sub=sub, scopes=scopes, target=target) + + # Mock request to get token info + def _oidc_introspection_callback(request): + payload = urllib.parse.parse_qs(request.body) + token = payload["token"][0] + if token != oidc_access_token: + return (200, {}, json.dumps({"active": False})) + return ( + 200, + {}, + json.dumps( + _mock_oidc_introspection_response(sub=sub, scopes=scopes, target=target) + ), + ) + + responses.add_callback( + responses.POST, + provider_config["introspection_endpoint"], + callback=protect_oidc_client_basic_callback( + _oidc_introspection_callback, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ), + ) + + # Mock request to get ID token + def _oidc_userinfo_callback(request): + if sub is None: + return (401, {}, "") + + user_info = _mock_oidc_user_info_plain_response( + sub=sub, scopes=scopes, target=target + ) + if userinfo_response_type == "plain": + return (200, {"Content-Type": "application/json"}, json.dumps(user_info)) + elif userinfo_response_type == "jwt": + encoded_user_info = encode_jwt( + claims=user_info, + algorithm=ALGORITHM, + headers={ + "kid": PUBLIC_KEY_ID, + }, + ) + return ( + 200, + {"Content-Type": "application/jwt"}, + encoded_user_info, + ) + else: + return (400, {}, "") + + responses.add_callback( + responses.GET, + provider_config["userinfo_endpoint"], + callback=protect_oidc_token_callback( + _oidc_userinfo_callback, access_token=oidc_access_token + ), + ) + + return oidc_access_token @pytest.fixture -def encoded_token(): - """Encode token with the private key (fixture).""" - return _create_oidc_token(sub="123|oidc", scopes=["all", "statements/read"]) +def access_token(): + """Get opaque OAuth2 access token (fixture).""" + return _mock_access_token(sub="123|oidc", scopes=["all", "statements/read"]) diff --git a/tests/helpers.py b/tests/helpers.py index 1f5a45d53..b698aad2b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,7 +11,7 @@ from ralph.api.auth import AuthBackend from ralph.utils import statements_are_equivalent -from tests.fixtures.auth import AUDIENCE, ISSUER_URI +from tests.fixtures.auth import AUDIENCE, CLIENT_ID, CLIENT_SECRET, ISSUER_URI def string_is_date(string: str): @@ -210,7 +210,9 @@ def mock_statement( def configure_env_for_mock_oidc_auth( - monkeypatch, runserver_auth_backends: List[AuthBackend] = None + monkeypatch, + runserver_auth_backends: List[AuthBackend] = None, + enable_oidc_client: Optional[bool] = True, ): """Configure environment variables to simulate OIDC use.""" @@ -232,3 +234,12 @@ def configure_env_for_mock_oidc_auth( "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", AUDIENCE, ) + if enable_oidc_client: + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_ID", + CLIENT_ID, + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET", + CLIENT_SECRET, + )