diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f3b6ce9f..11dfa2b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ and this project adheres to ### Fixed - Fix type of `statement.result.score.scaled` from `int` to `Decimal` +- Fix type of OIDC ID tokens +- Fix error with OIDC scopes unrelated to Ralph + +### Changed + +- 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/ralph/api/auth/oidc.py b/src/ralph/api/auth/oidc.py index 903d9b543..124c7e511 100644 --- a/src/ralph/api/auth/oidc.py +++ b/src/ralph/api/auth/oidc.py @@ -1,8 +1,9 @@ """OpenID Connect authentication tool for the Ralph API.""" +import base64 import logging from functools import lru_cache -from typing import Dict, Optional +from typing import Dict, Literal, Optional, Union, get_args import requests from fastapi import Depends, HTTPException, status @@ -12,7 +13,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,29 +27,61 @@ 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 TokenInfo(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") @@ -72,6 +105,129 @@ def discover_provider(base_url: AnyUrl) -> Dict: ) from exc +def get_user_info(provider_config: dict, access_token: str) -> UserInfo: + """Get the user's info from the IdP using the /userinfo OIDC endpoint.""" + user_info, is_encoded = get_user_info_data( + provider_config["userinfo_endpoint"], access_token + ) + if not is_encoded: + # nothing to do + return UserInfo.model_validate(user_info) + return decode_user_info(user_info, provider_config=provider_config) + + +@lru_cache() +def get_user_info_data( + userinfo_endpoint: AnyUrl, access_token: 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": f"Bearer {access_token}"}, + timeout=5, + ) + response.raise_for_status() + content_type = response.headers["Content-Type"] + return (response.json(), content_type.lower() == "application/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_user_info(encoded_user_info: str, provider_config: dict) -> UserInfo: + """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: + decoded_token = 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 + + return UserInfo.model_validate(decoded_token) + + +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") + + +@lru_cache() +def get_token_info( + introspection_endpoint: AnyUrl, token: str, client_id: str, client_secret: str +) -> UserInfo: + """Get info on given token from the IdP using /introspection OIDC endpoint.""" + token_info = None + try: + response = requests.post( + f"{introspection_endpoint}", + headers={ + "Authorization": f"Basic {encode_client_secret_basic_token( + client_id=client_id, + client_secret=client_secret + )}" + }, + 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 TokenInfo.model_validate(token_info) + + @lru_cache() def get_public_keys(jwks_uri: AnyUrl) -> Dict: """Retrieve the public keys used by the provider server for signing.""" @@ -94,6 +250,19 @@ 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 get_oidc_user( auth_header: Annotated[Optional[HTTPBearer], Depends(oauth2_scheme)], ) -> AuthenticatedUser: @@ -117,37 +286,38 @@ 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, - ) - 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 - - id_token = IDToken.model_validate(decoded_token) - 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_info( + provider_config["introspection_endpoint"], + token=access_token, + client_id=settings.RUNSERVER_AUTH_OIDC_CLIENT_ID, + client_secret=settings.RUNSERVER_AUTH_OIDC_CLIENT_SECRET, ) - return user + if token_info.sub: + # This is a real user, we can retrieve their user info + user_info = get_user_info(provider_config, access_token=access_token) + 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 AuthenticatedUser( + agent={"openid": f"{token_info.iss}/user/{user_info.sub}"}, + scopes=get_user_scopes(user_info.scope), + target=user_info.target, + ) + else: + # 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, + ) diff --git a/src/ralph/conf.py b/src/ralph/conf.py index 6dfba7ed6..43d972853 100644 --- a/src/ralph/conf.py +++ b/src/ralph/conf.py @@ -1,5 +1,6 @@ """Configurations for Ralph.""" +import os import io from enum import Enum from pathlib import Path @@ -40,6 +41,7 @@ BASE_SETTINGS_CONFIG = SettingsConfigDict( case_sensitive=True, env_nested_delimiter="__", env_prefix="RALPH_", extra="ignore" + , secrets_dir=os.environ.get("RALPH_SECRETS_DIR") ) @@ -203,6 +205,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..3123da6a2 100644 --- a/tests/api/auth/test_oidc.py +++ b/tests/api/auth/test_oidc.py @@ -19,18 +19,18 @@ @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_get_whoami_valid( - client, monkeypatch, runserver_auth_backends + client, monkeypatch, runserver_auth_backends, userinfo_response_type ): """Test a valid OpenId Connect authentication.""" configure_env_for_mock_oidc_auth(monkeypatch, runserver_auth_backends) - oidc_token = mock_oidc_user(scopes=["all", "profile/read"]) + oidc_token = mock_oidc_user(scopes=["all", "profile/read"], userinfo_response_type=userinfo_response_type) headers = {"Authorization": f"Bearer {oidc_token}"} response = await client.get( @@ -40,7 +40,7 @@ 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", + "openid": "https://iss.example.com/user/123|oidc", "objectType": "Agent", } assert TypeAdapter(BaseXapiAgentWithOpenId).validate_python( @@ -52,12 +52,12 @@ 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 +65,7 @@ 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 +105,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 +132,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.""" @@ -148,7 +152,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 +163,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 +191,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 diff --git a/tests/api/test_statements_get.py b/tests/api/test_statements_get.py index b6bc6d691..f6e343d95 100644 --- a/tests/api/test_statements_get.py +++ b/tests/api/test_statements_get.py @@ -30,7 +30,14 @@ 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, + mock_basic_auth_user, + mock_oidc_user, +) from ..helpers import mock_activity, mock_agent @@ -889,10 +896,18 @@ 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"{iss}/user/{sub}"} oidc_token = mock_oidc_user(sub=sub, scopes=scopes) headers = {"Authorization": f"Bearer {oidc_token}"} 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..9945a1db9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from .fixtures.api import client # noqa: F401 from .fixtures.auth import ( # noqa: F401 basic_auth_credentials, - encoded_token, + access_token, mock_discovery_response, mock_oidc_jwks, ) diff --git a/tests/fixtures/auth.py b/tests/fixtures/auth.py index 68326a5f8..33d65d053 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 Literal, Optional import bcrypt import pytest @@ -21,6 +22,9 @@ ALGORITHM = "RS256" AUDIENCE = "http://clientHost:8100" ISSUER_URI = "http://providerHost:8080/auth/realms/real_name" +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 +120,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", @@ -229,33 +234,47 @@ def mock_oidc_jwks(): return _mock_oidc_jwks() -def _create_oidc_token(sub, scopes, target=None): - """Encode token with the private key.""" - claims = { +def _mock_access_token(sub, scopes, target=None): + return base64.urlsafe_b64encode( + f"opaque_string_{sub}_{scopes}_{target}".encode() + ).decode() + + +def _mock_oidc_token_info(sub, scopes, target=None): + """Mock OIDC Token Introspection response with provided params.""" + user_info = { "sub": sub, "iss": "https://iss.example.com", "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 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, - }, - ) + user_info["target"] = target + return user_info + +def _mock_oidc_user_info_plain(sub, scopes, target=None): + """Mock unencoded OIDC user info claims with provided params.""" + 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): + +def mock_oidc_user( + sub="123|oidc", + scopes=None, + target=None, + userinfo_response_type: Literal["plain", "jwt"] = "jwt", +): """Instantiate mock oidc user and return auth token.""" # Default value for scope if scopes is None: @@ -265,27 +284,98 @@ def mock_oidc_user(sub="123|oidc", scopes=None, target=None): 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, ) + oidc_access_token = _mock_access_token(sub=sub, scopes=scopes, target=target) + # 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 + # Mock request to get token info + def _oidc_introspection_callback(request): + payload = urllib.parse.parse_qs(request.body) + 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") + client_id = decoded_client_secret_basic_token.split(":")[0] + client_secret = decoded_client_secret_basic_token.split(":")[1] + if client_id != CLIENT_ID or client_secret != CLIENT_SECRET: + return (401, {}, "") + token = payload["token"][0] + if token != oidc_access_token: + return (200, {}, json.dumps({"active": False})) + return ( + 200, + {}, + json.dumps(_mock_oidc_token_info(sub=sub, scopes=scopes, target=target)), + ) + + responses.add_callback( + responses.POST, + provider_config["introspection_endpoint"], + callback=_oidc_introspection_callback, + ) + + # Mock request to get ID token + def _oidc_userinfo_callback(request): + auth_header = request.headers["Authorization"] + auth_method = auth_header.split(" ")[0] + if auth_method.lower() != "bearer": + return (401, {}, "") + access_token = auth_header.split(" ")[-1] + if access_token != oidc_access_token: + return (401, {}, "") + user_info = _mock_oidc_user_info_plain(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 = jwt.encode( + claims=user_info, + key=private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + algorithm=ALGORITHM, + headers={ + "kid": PUBLIC_KEY_ID, + }, + ) + return ( + 200, + {"Content-Type": "application/jwt"}, + json.dumps(encoded_user_info), + ) + else: + return (500, {}, "") + + responses.add_callback( + responses.GET, + provider_config["userinfo_endpoint"], + callback=_oidc_userinfo_callback, + ) + + 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..b5330ff17 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, ISSUER_URI,CLIENT_ID,CLIENT_SECRET def string_is_date(string: str): @@ -232,3 +232,11 @@ def configure_env_for_mock_oidc_auth( "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, + )