From c586fd0e5f5684f21172eba22ac7d94f749efa1d Mon Sep 17 00:00:00 2001 From: piptouque Date: Wed, 18 Feb 2026 18:47:43 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B(oicd)=20fix=20types=20of=20id?= =?UTF-8?q?=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'aud' claim may be a list: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 The 'exp' and 'iat' claims are `NumericDate` and may be floats: https://www.rfc-editor.org/rfc/rfc7519#section-2 --- CHANGELOG.md | 1 + src/ralph/api/auth/oidc.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f3b6ce9f..8f63d3632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to ### Fixed - Fix type of `statement.result.score.scaled` from `int` to `Decimal` +- Fix type of OIDC ID tokens ## [5.0.1] - 2024-07-11 diff --git a/src/ralph/api/auth/oidc.py b/src/ralph/api/auth/oidc.py index 903d9b543..28c804695 100644 --- a/src/ralph/api/auth/oidc.py +++ b/src/ralph/api/auth/oidc.py @@ -2,7 +2,7 @@ import logging from functools import lru_cache -from typing import Dict, Optional +from typing import Dict, Optional, Union import requests from fastapi import Depends, HTTPException, status @@ -35,7 +35,7 @@ class IDToken(BaseModel): 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. + 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. @@ -45,9 +45,9 @@ class IDToken(BaseModel): iss: str sub: str - aud: Optional[str] = None - exp: int - iat: int + aud: Optional[Union[list[str], str]] = None + exp: float + iat: float scope: Optional[str] = None target: Optional[str] = None From 1f7d81ee90beecb99c6426e1527c6be2957c1796 Mon Sep 17 00:00:00 2001 From: piptouque Date: Wed, 18 Feb 2026 19:23:55 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B(oicd)=20fix=20ignore=20unrelat?= =?UTF-8?q?ed=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server should ignore any OAuth 2 scope that it does not know, instead of returning an error. --- CHANGELOG.md | 1 + src/ralph/api/auth/oidc.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f63d3632..7112de689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - 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 ## [5.0.1] - 2024-07-11 diff --git a/src/ralph/api/auth/oidc.py b/src/ralph/api/auth/oidc.py index 28c804695..e9cd0ba16 100644 --- a/src/ralph/api/auth/oidc.py +++ b/src/ralph/api/auth/oidc.py @@ -2,7 +2,7 @@ import logging from functools import lru_cache -from typing import Dict, Optional, Union +from typing import Dict, Optional, Union, get_args import requests from fastapi import Depends, HTTPException, status @@ -12,7 +12,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" @@ -94,6 +94,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: @@ -147,7 +160,7 @@ def get_oidc_user( user = AuthenticatedUser( agent={"openid": f"{id_token.iss}/{id_token.sub}"}, - scopes=UserScopes(id_token.scope.split(" ") if id_token.scope else []), + scopes=get_user_scopes(id_token.scope), target=id_token.target, ) return user From 998b35effe40a8d08a28ec376e2d0f74eca611dc Mon Sep 17 00:00:00 2001 From: piptouque Date: Tue, 17 Feb 2026 15:04:13 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8(oidc)=20get=20user=20claims=20fro?= =?UTF-8?q?m=20/userinfo=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ID token and access token are different and have different purpose. The ID token is always a JWT and contains user claims, but the access token may not be. With this change, we get the user claims using the access token, using the /userinfo OIDC enpoint, allowing us to support providers that return opaque access tokens. --- CHANGELOG.md | 4 ++ src/ralph/api/auth/oidc.py | 119 ++++++++++++++++++++++++------------ tests/api/auth/test_oidc.py | 24 +++++--- tests/fixtures/auth.py | 70 ++++++++++++++------- 4 files changed, 148 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7112de689..8c5a52015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to - 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 + ## [5.0.1] - 2024-07-11 ### Changed diff --git a/src/ralph/api/auth/oidc.py b/src/ralph/api/auth/oidc.py index e9cd0ba16..f1495f1f5 100644 --- a/src/ralph/api/auth/oidc.py +++ b/src/ralph/api/auth/oidc.py @@ -2,7 +2,7 @@ import logging from functools import lru_cache -from typing import Dict, Optional, Union, get_args +from typing import Dict, Optional, Union, Literal, get_args import requests from fastapi import Depends, HTTPException, status @@ -26,17 +26,19 @@ 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 core of an OpenID Connect UserInfo endpoint response. - 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. + + 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 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 + aud (str or list of str): Audience(s) that this ID Token/UserInfo is intended for. + exp (int): Expiration time on or after which the ID Token/UserInfo MUST NOT be accepted for processing. iat (int): Time at which the JWT was issued. scope (str): Scope(s) for resource authorization. @@ -71,6 +73,71 @@ def discover_provider(base_url: AnyUrl) -> Dict: headers={"WWW-Authenticate": "Bearer"}, ) 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) + @lru_cache() def get_public_keys(jwks_uri: AnyUrl) -> Dict: @@ -130,37 +197,13 @@ 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=get_user_scopes(id_token.scope), - target=id_token.target, + user_info = get_user_info( + provider_config, access_token=access_token + ) + return AuthenticatedUser( + agent={"openid": f"{user_info.iss}/{user_info.sub}"}, + scopes=get_user_scopes(user_info.scope), + target=user_info.target, ) - return user diff --git a/tests/api/auth/test_oidc.py b/tests/api/auth/test_oidc.py index c128662cf..43df27aa7 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( @@ -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", diff --git a/tests/fixtures/auth.py b/tests/fixtures/auth.py index 68326a5f8..1636fd538 100644 --- a/tests/fixtures/auth.py +++ b/tests/fixtures/auth.py @@ -3,7 +3,7 @@ import base64 import json import os -from typing import Optional +from typing import Optional, Literal import bcrypt import pytest @@ -116,6 +116,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,9 +230,9 @@ 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_oidc_user_info_plain(sub, scopes, target=None): + """Mock unencoded OIDC user info claims with provided params.""" + user_info = { "sub": sub, "iss": "https://iss.example.com", "aud": AUDIENCE, @@ -240,22 +241,11 @@ def _create_oidc_token(sub, scopes, target=None): "scope": " ".join(scopes), } 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(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: @@ -273,6 +263,9 @@ def mock_oidc_user(sub="123|oidc", scopes=None, target=None): status=200, ) + oidc_access_token = base64.urlsafe_b64encode( + f"opaque_string_{sub}_{scopes}_{target}".encode() + ).decode() # Mock request to get keys responses.add( responses.GET, @@ -281,11 +274,46 @@ def mock_oidc_user(sub="123|oidc", scopes=None, target=None): status=200, ) - oidc_token = _create_oidc_token(sub=sub, scopes=scopes, target=target) - return oidc_token + # Mock request to get user info + 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, {}, "") + + # Mock request to get ID token + responses.add_callback( + responses.GET, + _mock_discovery_response()["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"]) + return _mock_oidc_user_info_plain(sub="123|oidc", scopes=["all", "statements/read"]) From 51a49e970ba18a8f52690690102a0c7d7b2eb61a Mon Sep 17 00:00:00 2001 From: piptouque Date: Tue, 17 Feb 2026 17:27:53 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8(oidc)=20add=20support=20for=20cli?= =?UTF-8?q?ent=20apps=20as=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client applications, as authenticated with the 'client_credentials' flow, do not have a dedicated user. As such, we can't get ID tokens for them, always opaque access tokens. Instead, we authenticate them using their client_id. But first, we need to check whether the access token we got was from a real oauth2 user or a client app. To do that, we query the /introspection endpoint of our IdP. This requires that Ralph be registered s another client app to our IdP, and to have configured its client ID and secret. --- CHANGELOG.md | 1 + docs/tutorials/lrs/authentication/oidc.md | 18 ++- src/ralph/api/auth/oidc.py | 150 +++++++++++++++++++--- src/ralph/conf.py | 4 + tests/api/auth/test_oidc.py | 10 +- tests/api/test_statements_get.py | 19 ++- tests/api/test_statements_post.py | 10 ++ tests/api/test_statements_put.py | 10 ++ tests/conftest.py | 2 +- tests/fixtures/auth.py | 98 +++++++++++--- tests/helpers.py | 10 +- 11 files changed, 281 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5a52015..11dfa2b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to ### 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 f1495f1f5..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, Union, Literal, get_args +from typing import Dict, Literal, Optional, Union, get_args import requests from fastapi import Depends, HTTPException, status @@ -27,30 +28,60 @@ class UserInfo(BaseModel): - """Pydantic model representing the core of an OpenID Connect UserInfo endpoint response. + """Pydantic model representing the UserInfo response of the OIDC IdP. 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 or list of str): Audience(s) that this ID Token/UserInfo is intended for. - exp (int): Expiration time on or after which the ID Token/UserInfo MUST NOT be + 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 + 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") @@ -73,9 +104,12 @@ def discover_provider(base_url: AnyUrl) -> Dict: headers={"WWW-Authenticate": "Bearer"}, ) 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) + 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) @@ -83,10 +117,13 @@ def get_user_info(provider_config: dict, access_token: str) -> UserInfo: @lru_cache() -def get_user_info_data(userinfo_endpoint: AnyUrl, access_token: str) -> Union[tuple[dict, Literal[False]], tuple[str, Literal[True]]]: +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 + 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 @@ -139,6 +176,58 @@ def decode_user_info(encoded_user_info: str, provider_config: dict) -> UserInfo: 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.""" @@ -199,11 +288,36 @@ def get_oidc_user( access_token = auth_header.split(" ")[-1] provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI) - user_info = get_user_info( - provider_config, access_token=access_token - ) - return AuthenticatedUser( - agent={"openid": f"{user_info.iss}/{user_info.sub}"}, - scopes=get_user_scopes(user_info.scope), - target=user_info.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, ) + 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 43df27aa7..3123da6a2 100644 --- a/tests/api/auth/test_oidc.py +++ b/tests/api/auth/test_oidc.py @@ -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( @@ -132,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.""" @@ -152,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 @@ -163,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.""" @@ -191,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 1636fd538..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, Literal +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" @@ -230,8 +234,14 @@ def mock_oidc_jwks(): return _mock_oidc_jwks() -def _mock_oidc_user_info_plain(sub, scopes, target=None): - """Mock unencoded OIDC user info claims with provided params.""" +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", @@ -239,13 +249,32 @@ def _mock_oidc_user_info_plain(sub, scopes, target=None): "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: + 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, userinfo_response_type: Literal["plain", "jwt"] = "jwt"): +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: @@ -255,26 +284,56 @@ def mock_oidc_user(sub="123|oidc", scopes=None, target=None, userinfo_response_t 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 = base64.urlsafe_b64encode( - f"opaque_string_{sub}_{scopes}_{target}".encode() - ).decode() + 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, ) - # Mock request to get user info + # 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] @@ -285,7 +344,7 @@ def _oidc_userinfo_callback(request): 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)) + return (200, {"Content-Type": "application/json"}, json.dumps(user_info)) elif userinfo_response_type == "jwt": encoded_user_info = jwt.encode( claims=user_info, @@ -299,21 +358,24 @@ def _oidc_userinfo_callback(request): "kid": PUBLIC_KEY_ID, }, ) - return (200, {'Content-Type': 'application/jwt'}, json.dumps(encoded_user_info)) + return ( + 200, + {"Content-Type": "application/jwt"}, + json.dumps(encoded_user_info), + ) else: return (500, {}, "") - # Mock request to get ID token responses.add_callback( responses.GET, - _mock_discovery_response()["userinfo_endpoint"], - callback=_oidc_userinfo_callback + 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 _mock_oidc_user_info_plain(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, + )